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
+94 -26
View File
@@ -5,8 +5,8 @@ use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
use sanctum::crypto::{
dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek, wrap_dek,
KdfParams, FORMAT_VERSION,
dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek,
wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION,
};
use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth};
use sanctum::recovery::{
@@ -37,6 +37,14 @@ enum Commands {
/// Erstellt zusätzlich einen plausibel abstreitbaren Hidden Vault (Plausible Deniability)
#[arg(long, default_value_t = false)]
with_hidden: bool,
/// Dateiname der Trägerdatei im Decoy-Vault (Standard: backup_image.iso)
#[arg(long, default_value = "backup_image.iso")]
carrier_name: String,
/// Größe der Trägerdatei (z. B. 100MB, 500MB, 1GB, 2GB; Standard: 1GB)
#[arg(long, default_value = "1GB")]
carrier_size: String,
},
/// Kompaktiert den Container-Speicherplatz (Incremental Vacuum) und bereinigt ungenutzte Seiten
@@ -172,7 +180,30 @@ fn parse_drive_letter(s: &str) -> Result<char> {
Ok(ch.to_ascii_uppercase())
}
fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> {
pub fn parse_size_string(s: &str) -> Result<u64> {
let trimmed = s.trim().to_uppercase();
if let Some(num_str) = trimmed.strip_suffix("GB") {
let n: u64 = num_str.trim().parse().context("Ungültige Gigabyte-Angabe")?;
Ok(n * 1024 * 1024 * 1024)
} else if let Some(num_str) = trimmed.strip_suffix("MB") {
let n: u64 = num_str.trim().parse().context("Ungültige Megabyte-Angabe")?;
Ok(n * 1024 * 1024)
} else if let Some(num_str) = trimmed.strip_suffix("KB") {
let n: u64 = num_str.trim().parse().context("Ungültige Kilobyte-Angabe")?;
Ok(n * 1024)
} else if let Ok(n) = trimmed.parse::<u64>() {
Ok(n)
} else {
bail!("Ungültiges Größenformat: '{}'. Erwartet z. B. '500MB', '1GB', '2GB'", s);
}
}
fn handle_init(
container_path: &Path,
with_hidden: bool,
carrier_name: &str,
carrier_size_str: &str,
) -> Result<()> {
if container_path.exists() {
bail!(
"Zieldatei '{}' existiert bereits. Initialisierung abgebrochen, um Überschreiben zu verhindern.",
@@ -185,11 +216,17 @@ fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> {
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Zieldatei: {}", container_path.display());
if with_hidden {
println!(" Modus: Dual-Vault (Standard + {})", ui::magenta("Hidden Vault"));
println!(" Modus: Dual-Vault (Modell A: {})", ui::magenta("Alibi-Carrier"));
println!(" Alibi: {} ({})", ui::cyan(carrier_name), ui::cyan(carrier_size_str));
}
println!();
if with_hidden {
let carrier_size_bytes = parse_size_string(carrier_size_str)?;
if carrier_size_bytes < 2 * 1024 * 1024 {
bail!("Trägerdateigröße muss mindestens 2 MB betragen (Block 0 Manifest + mindestens 1 Datenblock)");
}
println!(" ─── [1/2] Standard-Vault (Äußerer Container / Decoy) ───");
let password_0 = rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
.context("Fehler beim Einlesen des Passworts")?;
@@ -233,21 +270,32 @@ fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> {
let dek_0 = generate_dek();
let dek_1 = generate_dek();
ui::step(3, 4, "🔒", "Verschlüssele beide DEKs unabhängig via AES-256-GCM...");
let (wrapped_dek_0, nonce_0, tag_0) = wrap_dek(&kek_0, &dek_0)?;
let (wrapped_dek_1, nonce_1, tag_1) = wrap_dek(&kek_1, &dek_1)?;
ui::step(3, 4, "🔒", "Verschlüssele Slot-Payloads für Modell A via AES-256-GCM...");
let carrier_node_id = 3i64;
let (wrapped_dek_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id)?;
let (wrapped_dek_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id)?;
ui::step(4, 4, "📦", "Initialisiere SQLite-Container mit Dual-Slot Header & WAL-Modus...");
ui::step(4, 4, "📦", &format!("Initialisiere Alibi-Carrier '{}' ({}) & Container...", carrier_name, carrier_size_str));
let db = Database::open(container_path)
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
db.init_schema_with_hidden(
db.init_schema_with_carrier(
&salt_0,
&kdf_params_0,
&wrapped_dek_0,
&nonce_0,
&tag_0,
Some((&salt_1, &kdf_params_1, &wrapped_dek_1, &nonce_1, &tag_1)),
Some((
carrier_name,
carrier_size_bytes,
&salt_1,
&kdf_params_1,
&wrapped_dek_1,
&nonce_1,
&tag_1,
&dek_0,
&dek_1,
)),
)?;
db.checkpoint()?;
@@ -257,14 +305,16 @@ fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> {
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Sanctum Dual-Vault Container erfolgreich initialisiert! │");
println!("│ ✔ Sanctum Dual-Vault Container (Modell A) initialisiert! ");
println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Container: {}", container_path.display());
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
println!(" • Alibi-Datei: {} ({})", carrier_name, carrier_size_str);
println!(" • KDF: Argon2id pro Slot (M=64MB, T=3, P=4)");
println!("Verschlüssel: AES-256-GCM + LZ4-Kompression + Dateinamens-Verschleierung");
println!("Deniability: Beide Slots besitzen identische Struktur und Bit-Entropie");
println!("Steganografie:Hidden Vault verbirgt sich vollständig in der Trägerdatei");
println!("Accounting: 100% aller Chunks authentifizieren fehlerfrei unter DEK_0");
println!(" • Dateigröße: Feste Containergröße — kein dynamisches Wachstum auf Festplatte");
println!();
println!(" Befehl zum Einbinden als Netzlaufwerk:");
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display())));
@@ -307,13 +357,13 @@ fn handle_init(container_path: &Path, with_hidden: bool) -> Result<()> {
ui::step(3, 4, "🔒", "Verschlüssele DEK via AES-256-GCM...");
let (wrapped_dek, header_nonce, header_tag) =
wrap_dek(&kek, &dek).context("DEK-Wrapping fehlgeschlagen")?;
wrap_slot0_payload(&kek, &dek, 0).context("DEK-Wrapping fehlgeschlagen")?;
ui::step(4, 4, "📦", "Initialisiere SQLite-Containerstruktur & WAL-Modus...");
let db = Database::open(container_path)
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)
db.init_schema_with_carrier(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag, None)
.context("Fehler bei der Schema-Initialisierung")?;
db.checkpoint()
@@ -406,11 +456,11 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let (dek, target_slot_id) = if let Some(phrase) = recovery_key {
let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(phrase) = recovery_key {
ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel...");
let d = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
(d, 0)
(d, 0, None, None)
} else {
let old_password = rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des aktuellen Passworts")?;
@@ -426,10 +476,12 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
.context("Konnte Container-Header nicht lesen")?;
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort (konstante Zeit)...");
let (d, _ver, slot_id) = meta
let keys = meta
.authenticate(&old_password)
.ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?;
(d, slot_id)
let d = keys.dek().clone();
let slot_id = keys.slot_id();
(d, slot_id, keys.carrier_dek(), keys.carrier_node_id())
};
println!();
@@ -454,8 +506,16 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
let new_kek = derive_kek(&new_password, &new_salt, &new_params)
.context("KDF-Schlüsselableitung mit neuem Passwort fehlgeschlagen")?;
let (new_wrapped_dek, new_nonce, new_tag) =
wrap_dek(&new_kek, &dek).context("DEK-Wrapping mit neuem KEK fehlgeschlagen")?;
let (new_wrapped_dek, new_nonce, new_tag) = if target_slot_id == 1 {
let c_dek = carrier_dek.unwrap_or_else(generate_dek);
let c_nid = carrier_node_id.unwrap_or(0);
wrap_slot1_payload(&new_kek, &dek, &c_dek, c_nid)
.context("Slot-1 Wrapping mit neuem KEK fehlgeschlagen")?
} else {
let c_nid = carrier_node_id.unwrap_or(0);
wrap_slot0_payload(&new_kek, &dek, c_nid)
.context("Slot-0 Wrapping mit neuem KEK fehlgeschlagen")?
};
ui::step(4, 4, "💾", "Aktualisiere Container-Header & führe Checkpoint aus...");
db.update_slot_keys(
@@ -580,9 +640,11 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> {
.context("Konnte Container-Datenbank nicht öffnen")?;
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
let (dek, _ver, slot_id) = meta
let keys = meta
.authenticate(&password)
.ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
let dek = keys.dek().clone();
let slot_id = keys.slot_id();
let phrase = dek_to_mnemonic(&dek)?;
if slot_id == 1 {
@@ -612,10 +674,11 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
let meta = db.read_meta().context("Konnte Header nicht lesen")?;
match meta.authenticate(&password) {
Some((d, _ver, slot_id)) => {
Some(keys) => {
let slot_id = keys.slot_id();
let vault_desc = if slot_id == 1 { "Hidden Vault (Slot 1)" } else { "Decoy Vault (Slot 0)" };
println!(" {} Master-Passwort verifiziert ({}). Führe kryptografische AEAD-Vollprüfung durch...", ui::green(""), ui::cyan(vault_desc));
Some(d)
Some(keys.dek().clone())
}
None => {
println!(" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", ui::yellow("⚠️"));
@@ -641,8 +704,13 @@ async fn run() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Init { path, with_hidden } => {
handle_init(&path, with_hidden)?;
Commands::Init {
path,
with_hidden,
carrier_name,
carrier_size,
} => {
handle_init(&path, with_hidden, &carrier_name, &carrier_size)?;
}
Commands::Compact { path, pages } => {
handle_compact(&path, pages)?;