feat(security): implement Phase 2 Modell A (Steganografischer Alibi-Carrier für Plausible Deniability)
This commit is contained in:
+94
-26
@@ -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)?;
|
||||
|
||||
Reference in New Issue
Block a user