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
+144 -91
View File
@@ -6,12 +6,14 @@ use tracing_subscriber::EnvFilter;
use zeroize::Zeroizing;
use sanctum::crypto::{
dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek,
wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION,
check_password_prefix_collision, dek_to_mnemonic, derive_kek, generate_dek, generate_salt,
mnemonic_to_dek, set_allow_legacy_names, validate_password, wrap_slot0_payload,
wrap_slot1_payload, KdfParams, FORMAT_VERSION,
};
use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth};
use sanctum::recovery::{
export_header_backup, restore_header_backup, restore_slot_from_recovery_key,
detect_recovery_key_slot, export_header_backup, restore_header_backup,
restore_slot_from_recovery_key,
};
use sanctum::storage::Database;
use sanctum::ui;
@@ -23,6 +25,10 @@ use sanctum::verify::verify_container;
#[command(about = "Sanctum: Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland", long_about = None)]
#[command(version)]
struct Cli {
/// Erlaubt Legacy-Namensentschlüsselung ohne deterministisches AAD (S-07)
#[arg(long, global = true, default_value_t = false)]
legacy_names: bool,
#[command(subcommand)]
command: Commands,
}
@@ -35,7 +41,7 @@ enum Commands {
#[arg(short, long)]
path: PathBuf,
/// Erstellt zusätzlich einen plausibel abstreitbaren Hidden Vault (Plausible Deniability)
/// Erstellt einen separaten, isolierten zweiten Tresor (Dual-Vault / Second Safe)
#[arg(long, default_value_t = false)]
with_hidden: bool,
@@ -267,6 +273,10 @@ enum Commands {
/// Benutzerdefinierte Gitea-Basis-URL (Standard: https://gitea.pansi.eu)
#[arg(long, default_value = "https://gitea.pansi.eu")]
url: String,
/// Erlaubt abweichende Update-Server-URLs (erfordert interaktive Bestätigung)
#[arg(long, default_value_t = false)]
insecure_url: bool,
},
}
@@ -302,11 +312,51 @@ pub fn parse_size_string(s: &str) -> Result<u64> {
}
}
/// Liest den 24-Wort BIP-39 Notfallschlüssel sicher ein.
/// Unterstützt:
/// 1. Interaktiv maskiert (wenn kein Wert oder leere Eingabe übergeben)
/// 2. Stdin (wenn "-" übergeben)
/// 3. Umgebungsvariable 'SANCTUM_RECOVERY_KEY' (wenn kein CLI-Argument übergeben)
/// 4. Veraltetes CLI-Argument mit Sicherheitswarnung (S-09)
fn resolve_recovery_key(arg: Option<&str>) -> Result<Option<Zeroizing<String>>> {
if let Some(key) = arg {
if key == "-" {
println!(" [{}] Lese 24-Wort Notfallschlüssel von der Standardeingabe (stdin)...", ui::cyan(""));
let mut line = String::new();
std::io::stdin().read_line(&mut line).context("Fehler beim Einlesen von stdin")?;
let trimmed = line.trim();
if trimmed.is_empty() {
bail!("Der Notfallschlüssel von stdin darf nicht leer sein.");
}
Ok(Some(Zeroizing::new(trimmed.to_string())))
} else if key.trim().is_empty() {
Ok(Some(prompt_recovery_key_interactive()?))
} else {
eprintln!(
"{} WARNUNG: Die Angabe des Notfallschlüssels als CLI-Argument ist unsicher und veraltet!",
ui::yellow("[SICHERHEITSHINWEIS]")
);
eprintln!(" Verwenden Sie '--recovery-key' ohne Wert (interaktiv), '--recovery-key -' (stdin) oder die Umgebungsvariable 'SANCTUM_RECOVERY_KEY'.");
Ok(Some(Zeroizing::new(key.to_string())))
}
} else if let Ok(env_key) = std::env::var("SANCTUM_RECOVERY_KEY") {
let trimmed = env_key.trim();
if !trimmed.is_empty() {
println!(" [{}] Verwende Notfallschlüssel aus Umgebungsvariable 'SANCTUM_RECOVERY_KEY'.", ui::cyan(""));
Ok(Some(Zeroizing::new(trimmed.to_string())))
} else {
Ok(None)
}
} else {
Ok(None)
}
}
/// Liest den 24-Wort BIP-39 Notfallschlüssel interaktiv und maskiert ein,
/// ohne dass Eingaben in der PowerShell-Historie (ConsoleHost_history.txt) landen.
fn prompt_recovery_key_interactive() -> Result<Zeroizing<String>> {
println!(" [{}] Geben Sie die 24 Notfall-Wörter durch Leerzeichen getrennt ein.", ui::cyan(""));
println!(" [{}] Die Eingabe erfolgt geschützt ohne Aufzeichnung in der PowerShell-Historie.", ui::dim("OpSec"));
println!(" [{}] Die Eingabe erfolgt geschützt ohne Aufzeichnung in der Shell-Historie.", ui::dim("OpSec"));
let phrase = rpassword::prompt_password("24-Wort Notfallschlüssel: ")
.context("Fehler beim Einlesen des Notfallschlüssels")?;
if phrase.trim().is_empty() {
@@ -333,15 +383,12 @@ fn handle_init(
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Zieldatei: {}", container_path.display());
if with_hidden {
println!(" Modus: Dual-Vault (Modell A: {})", ui::magenta("Alibi-Carrier"));
println!(" Alibi: {} ({})", ui::cyan(carrier_name), ui::cyan(carrier_size_str));
let lower = carrier_name.to_lowercase();
if lower.ends_with(".iso") || lower.ends_with(".vhd") || lower.ends_with(".img") || lower.ends_with(".zip") {
println!(
" [{}] Hinweis zur Plausible Deniability: Dateitypen wie '.iso' oder '.zip' besitzen ein starres Format. Da Carrier-Blöcke verschlüsselt sind (Entropie ~8.0), empfehlen sich unformatierte Typen wie '.dat', '.bin' oder '.enc' für maximale forensische Unauffälligkeit.",
ui::yellow("Tipp")
);
}
println!(" Modus: Dual-Vault (Modell A: {})", ui::magenta("Carrier-Datei"));
println!(" Carrier: {} ({})", ui::cyan(carrier_name), ui::cyan(carrier_size_str));
println!(
" [{}] Hinweis zum Dual-Vault: Modell A kapselt den Hidden Vault in einer Datei im Decoy-Vault. Dies schützt vor neugierigen Blicken, widersteht jedoch keinem forensischen Gutachten, da Carrier-Entropie und Schreibmuster analysierbar sind.",
ui::yellow("Info")
);
}
println!();
@@ -356,9 +403,8 @@ fn handle_init(
rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
.context("Fehler beim Einlesen des Passworts")?,
);
if password_0.trim().is_empty() {
bail!("Das Master-Passwort darf nicht leer sein.");
}
validate_password(&password_0)?;
let confirm_0 = Zeroizing::new(
rpassword::prompt_password("Master-Passwort für Standard-Vault bestätigen: ")
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
@@ -368,18 +414,15 @@ fn handle_init(
}
println!();
println!(" ─── [2/2] Hidden Vault (Versteckter Speicher / Plausible Deniability) ───");
println!(" ─── [2/2] Hidden Vault (Zweiter isolierter Tresor / Dual-Vault) ───");
println!(" [{}] Verwenden Sie ein völlig eigenständiges, separates Passwort!", ui::yellow("WICHTIG"));
let password_1 = Zeroizing::new(
rpassword::prompt_password("Master-Passwort für Hidden-Vault eingeben: ")
.context("Fehler beim Einlesen des Passworts")?,
);
if password_1.trim().is_empty() {
bail!("Das Master-Passwort für den Hidden-Vault darf nicht leer sein.");
}
if *password_1 == *password_0 {
bail!("Das Hidden-Vault-Passwort darf nicht mit dem Standard-Passwort identisch sein!");
}
validate_password(&password_1)?;
check_password_prefix_collision(&password_0, &password_1)?;
let confirm_1 = Zeroizing::new(
rpassword::prompt_password("Master-Passwort für Hidden-Vault bestätigen: ")
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
@@ -398,33 +441,36 @@ fn handle_init(
let kdf_params_1 = KdfParams::default();
let kek_1 = derive_kek(&password_1, &salt_1, &kdf_params_1)?;
ui::step(2, 4, "🎲", "Erzeuge getrennte DEKs für beide Vaults via CSPRNG...");
ui::step(2, 4, "🎲", "Erzeuge unabhängige DEKs (DEK_0 & DEK_1)...");
let dek_0 = generate_dek();
let dek_1 = generate_dek();
ui::step(3, 4, "🔒", "Verschlüssele Slot-Payloads für Modell A via AES-256-GCM...");
ui::step(3, 4, "🔒", "Verschlüssele Slot-Payloads (Dual-Vault Key-Wrapping)...");
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)?;
let (wrapped_dek_0, header_nonce_0, header_tag_0) =
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id)?;
ui::step(4, 4, "📦", &format!("Initialisiere Alibi-Carrier '{}' ({}) & Container...", carrier_name, carrier_size_str));
ui::step(4, 4, "📦", "Erzeuge SQLite-Container & alloziiere Alibi-Carrier-Datei...");
let db = Database::open(container_path)
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
let (wrapped_dek_1, header_nonce_1, header_tag_1) =
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id)?;
db.init_schema_with_carrier(
&salt_0,
&kdf_params_0,
&wrapped_dek_0,
&nonce_0,
&tag_0,
&header_nonce_0,
&header_tag_0,
Some((
carrier_name,
carrier_size_bytes,
&salt_1,
&kdf_params_1,
&wrapped_dek_1,
&nonce_1,
&tag_1,
&header_nonce_1,
&header_tag_1,
&dek_0,
&dek_1,
)),
@@ -437,16 +483,16 @@ fn handle_init(
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Sanctum Dual-Vault Container (Modell A) initialisiert! │");
println!("│ ✔ Sanctum Dual-Vault Container 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!("Carrier-Datei:{} ({})", carrier_name, carrier_size_str);
println!(" • KDF: Argon2id pro Slot (M=64MB, T=3, P=4)");
println!("Steganografie:Hidden Vault verbirgt sich vollständig in der Trägerdatei");
println!("Kapselung: Hidden Vault liegt in Carrier-Datei im Decoy-Vault");
println!(" • Accounting: 100% aller Chunks authentifizieren fehlerfrei unter DEK_0");
println!(" • Dateigröße: Feste Containergröße — kein dynamisches Wachstum auf Festplatte");
println!(" • Dateigröße: Feste Trägergröße zur Vermeidung von Größenveränderungen");
println!();
println!(" Befehl zum Einbinden als Netzlaufwerk:");
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display())));
@@ -467,10 +513,7 @@ fn handle_init(
rpassword::prompt_password("Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des Passworts")?,
);
if password.trim().is_empty() {
bail!("Das Master-Passwort darf nicht leer sein.");
}
validate_password(&password)?;
let confirm_password = Zeroizing::new(
rpassword::prompt_password("Master-Passwort bestätigen: ")
@@ -517,7 +560,7 @@ fn handle_init(
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
println!(" • KDF: Argon2id (M=64MB, T=3, P=4)");
println!(" • Cipher: AES-256-GCM + LZ4-Kompression (1-MB Chunks, AEAD)");
println!(" • Deniability: Slot 1 mit CSPRNG-Zufallsrauschen gefüllt");
println!(" • Dual-Vault: Slot 1 mit Zufallsrauschen initialisiert (inaktiv)");
println!();
println!(" Befehl zum Einbinden als Netzlaufwerk:");
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display())));
@@ -592,16 +635,26 @@ 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, carrier_dek, carrier_node_id) = if let Some(phrase) = recovery_key {
let actual_phrase = if phrase.trim().is_empty() {
prompt_recovery_key_interactive()?
} else {
Zeroizing::new(phrase.to_string())
};
let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(actual_phrase) = resolve_recovery_key(recovery_key)? {
ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel...");
let d = mnemonic_to_dek(&actual_phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
(d, 0, None, None)
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
let detected_slot = detect_recovery_key_slot(&db, &d, meta.version).unwrap_or(0);
if detected_slot == 1 {
println!(" Notfallschlüssel gehört zu Slot 1 (Hidden Vault).");
println!(" Zur Neuverpackung von Slot 1 wird das Passwort des Decoy-Tresors (Slot 0) benötigt.");
let decoy_pw = Zeroizing::new(
rpassword::prompt_password("Passwort für Decoy-Tresor (Slot 0): ")
.context("Fehler beim Einlesen des Decoy-Passworts")?
);
let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen."))?;
let c_dek = decoy_auth.dek().clone();
(d, 1, Some(c_dek), db.find_carrier_node_id()?)
} else {
(d, 0, None, db.find_carrier_node_id()?)
}
} else {
let old_password = Zeroizing::new(
rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
@@ -632,10 +685,7 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
rpassword::prompt_password("Neues Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des neuen Passworts")?,
);
if new_password.trim().is_empty() {
bail!("Das neue Master-Passwort darf nicht leer sein.");
}
validate_password(&new_password)?;
let confirm_password = Zeroizing::new(
rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
@@ -654,7 +704,7 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
.context("KDF-Schlüsselableitung mit neuem Passwort 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_dek = carrier_dek.ok_or_else(|| anyhow::anyhow!("DEK_0 (Träger-Schlüssel) für Slot 1 fehlt"))?;
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")?
@@ -810,12 +860,8 @@ fn handle_restore_header(
println!();
println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!"));
} else {
let key_str = if let Some(key) = recovery_key {
if key.trim().is_empty() {
prompt_recovery_key_interactive()?
} else {
Zeroizing::new(key.to_string())
}
let key_str = if let Some(key) = resolve_recovery_key(recovery_key)? {
key
} else {
// Interaktiver Auswahldialog statt Fehlermeldung
println!(" Keine Wiederherstellungsquelle als CLI-Argument angegeben.");
@@ -840,15 +886,21 @@ fn handle_restore_header(
}
};
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...", slot);
let dek = mnemonic_to_dek(&key_str).context("Ungültiger 24-Wort Notfallschlüssel")?;
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
let effective_slot = if slot == 0 {
detect_recovery_key_slot(&db, &dek, FORMAT_VERSION).unwrap_or(0)
} else {
slot
};
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...", effective_slot);
let new_password = Zeroizing::new(
rpassword::prompt_password("Neues Master-Passwort festlegen: ")
.context("Fehler beim Einlesen des Passworts")?,
);
if new_password.trim().is_empty() {
bail!("Das Master-Passwort darf nicht leer sein.");
}
validate_password(&new_password)?;
let confirm_password = Zeroizing::new(
rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
@@ -859,9 +911,24 @@ fn handle_restore_header(
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
}
let dek_0_holder: Option<[u8; 32]> = if effective_slot == 1 {
println!(" Wiederherstellung von Slot 1 (Hidden Vault):");
println!(" Zur mathematischen Rekonstruktion wird das Passwort des Decoy-Tresors (Slot 0) benötigt.");
let decoy_pw = Zeroizing::new(
rpassword::prompt_password("Passwort für Decoy-Tresor (Slot 0): ")
.context("Fehler beim Einlesen des Decoy-Passworts")?
);
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen."))?;
Some(**decoy_auth.dek())
} else {
None
};
let dek_0_opt = dek_0_holder.as_ref();
ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab...");
restore_slot_from_recovery_key(container_path, &key_str, &new_password, slot)?;
ui::step(2, 2, "💾", &format!("Header für Slot {} mit neuem Passwort neu synthetisiert!", slot));
restore_slot_from_recovery_key(container_path, &key_str, &new_password, effective_slot, dek_0_opt)?;
ui::step(2, 2, "💾", &format!("Header für Slot {} mit neuem Passwort neu synthetisiert!", effective_slot));
println!();
println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!"));
}
@@ -962,18 +1029,8 @@ fn handle_sync(
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
}
let auth = if let Some(key_arg) = recovery_key {
let phrase_str = if key_arg.trim().is_empty() {
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ 🔑 Maskierte interaktive Notfallschlüssel-Eingabe │");
println!("└─────────────────────────────────────────────────────────────┘");
println!();
rpassword::prompt_password("24-Wort Notfall-Wiederherstellungsschlüssel: ")
.context("Fehler beim Einlesen des Schlüssels")?
} else {
key_arg.to_string()
};
ContainerAuth::RecoveryKey(Zeroizing::new(phrase_str))
let auth = if let Some(phrase_str) = resolve_recovery_key(recovery_key)? {
ContainerAuth::RecoveryKey(phrase_str)
} else {
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — Ordner- & Dateisynchronisation (Sync) │");
@@ -999,11 +1056,7 @@ fn handle_sync(
ContainerAuth::RecoveryKey(ref phrase) => {
let dek = mnemonic_to_dek(phrase)
.context("Ungültiger 24-Wort Notfallschlüssel")?;
let is_hidden = {
let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default();
!children.is_empty()
};
let v_id = if is_hidden { 1 } else { 0 };
let v_id = detect_recovery_key_slot(&db, &dek, meta.version).unwrap_or(0);
(dek, meta.version, v_id)
}
};
@@ -1102,6 +1155,10 @@ fn handle_sync(
async fn run() -> Result<()> {
let cli = Cli::parse();
if cli.legacy_names {
set_allow_legacy_names(true);
}
match cli.command {
Commands::Init {
path,
@@ -1154,12 +1211,7 @@ async fn run() -> Result<()> {
}
}
};
let auth = if let Some(key) = recovery_key {
let actual_key = if key.trim().is_empty() {
prompt_recovery_key_interactive()?
} else {
Zeroizing::new(key)
};
let auth = if let Some(actual_key) = resolve_recovery_key(recovery_key.as_deref())? {
ContainerAuth::RecoveryKey(actual_key)
} else {
let prompt_text = format!(
@@ -1266,12 +1318,13 @@ async fn run() -> Result<()> {
println!(" {} .sanctum Dateiverknüpfungen wurden aus der Registry entfernt.", ui::green(""));
println!();
}
Commands::Upgrade { check, yes, force, url } => {
Commands::Upgrade { check, yes, force, url, insecure_url } => {
let options = sanctum::upgrade::UpgradeOptions {
check_only: check,
yes,
force,
base_url: url,
insecure_url,
};
sanctum::upgrade::run_upgrade(&options)?;
}