feat(security): release v0.7.0 with comprehensive security hardening (S-01 to S-11)
Sanctum Release / Build & Release (Windows x86_64) (push) Waiting to run

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
+138 -41
View File
@@ -40,12 +40,74 @@ impl Default for KdfParams {
}
}
pub const MIN_MEMORY_COST_KIB: u32 = 19_456;
pub const MAX_MEMORY_COST_KIB: u32 = 262_144;
pub const MIN_TIME_COST: u32 = 2;
pub const MAX_TIME_COST: u32 = 8;
pub const MIN_PARALLELISM: u32 = 1;
pub const MAX_PARALLELISM: u32 = 8;
pub const MIN_PASSWORD_LENGTH: usize = 12;
/// Validiert KDF-Parameter gegen DoS-Angriffe (z. B. Memory-Bombing mit 4 GB) und unsichere Parameter (S-04).
pub fn validate_kdf_params(params: &KdfParams) -> Result<()> {
if !(MIN_MEMORY_COST_KIB..=MAX_MEMORY_COST_KIB).contains(&params.memory_cost) {
bail!(
"KDF-Parameter ungültig: memory_cost = {} KiB (erlaubter Bereich: {}..={} KiB)",
params.memory_cost,
MIN_MEMORY_COST_KIB,
MAX_MEMORY_COST_KIB
);
}
if !(MIN_TIME_COST..=MAX_TIME_COST).contains(&params.time_cost) {
bail!(
"KDF-Parameter ungültig: time_cost = {} (erlaubter Bereich: {}..={})",
params.time_cost,
MIN_TIME_COST,
MAX_TIME_COST
);
}
if !(MIN_PARALLELISM..=MAX_PARALLELISM).contains(&params.parallelism) {
bail!(
"KDF-Parameter ungültig: parallelism = {} (erlaubter Bereich: {}..={})",
params.parallelism,
MIN_PARALLELISM,
MAX_PARALLELISM
);
}
Ok(())
}
/// Validiert, dass ein Master-Passwort die geforderte Mindestlänge (>= 12 Zeichen) aufweist (S-10).
pub fn validate_password(password: &str) -> Result<()> {
if password.chars().count() < MIN_PASSWORD_LENGTH {
bail!(
"Das Master-Passwort muss mindestens {} Zeichen lang sein (eingegeben: {} Zeichen).",
MIN_PASSWORD_LENGTH,
password.chars().count()
);
}
Ok(())
}
/// Prüft, ob bei Dual-Vault ein Passwort das Präfix des anderen ist oder identisch ist.
pub fn check_password_prefix_collision(pass0: &str, pass1: &str) -> Result<()> {
if pass0 == pass1 {
bail!("Die Passwörter für Standard-Vault und Hidden-Vault sind identisch!");
} else if pass0.starts_with(pass1) || pass1.starts_with(pass0) {
bail!("Ein Passwort ist ein Präfix des anderen! Dies schwächt die Sicherheit.");
} else {
Ok(())
}
}
/// Leitet aus dem Master-Passwort und dem Salt einen 256-Bit Key Encryption Key (KEK) via Argon2id ab.
pub fn derive_kek(
password: &str,
salt: &[u8],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
validate_kdf_params(params)?;
let argon2_params = Params::new(
params.memory_cost,
params.time_cost,
@@ -56,10 +118,13 @@ pub fn derive_kek(
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
let mut kek = Zeroizing::new([0u8; 32]);
let _ = crate::windows::lock_memory(kek.as_ptr(), 32);
argon2
.hash_password_into(password.as_bytes(), salt, &mut *kek)
.map_err(|e| anyhow::anyhow!("Argon2id KDF-Berechnung fehlgeschlagen: {e}"))?;
let res = argon2.hash_password_into(password.as_bytes(), salt, &mut *kek);
if let Err(e) = res {
let _ = crate::windows::unlock_memory(kek.as_ptr(), 32);
bail!("Argon2id KDF-Berechnung fehlgeschlagen: {e}");
}
Ok(kek)
}
@@ -67,6 +132,7 @@ pub fn derive_kek(
/// Generiert einen kryptografisch sicheren 256-Bit Data Encryption Key (DEK).
pub fn generate_dek() -> Zeroizing<[u8; 32]> {
let mut dek = Zeroizing::new([0u8; 32]);
let _ = crate::windows::lock_memory(dek.as_ptr(), 32);
OsRng.fill_bytes(&mut *dek);
dek
}
@@ -200,6 +266,17 @@ pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
aad
}
static ALLOW_LEGACY_NAMES: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Aktiviert oder deaktiviert den veralteten AAD-Fallback für Dateinamen (S-10).
pub fn set_allow_legacy_names(allow: bool) {
ALLOW_LEGACY_NAMES.store(allow, std::sync::atomic::Ordering::Relaxed);
}
pub fn allows_legacy_names() -> bool {
ALLOW_LEGACY_NAMES.load(std::sync::atomic::Ordering::Relaxed)
}
/// Verschlüsselt den Dateinamen für Knoten im Hidden Vault mit AES-256-GCM und bindet die parent_id als AAD ein.
/// Verhindert, dass unverschlüsselte Dateinamen in der SQLite-Datenbank forensisch auffindbar sind
/// und verhindert, dass verschlüsselte Knoten zwischen Ordnern verschoben oder vertauscht werden können.
@@ -207,12 +284,16 @@ pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let cipher = Aes256Gcm::new_from_slice(dek).expect("AES init");
let cipher = match Aes256Gcm::new_from_slice(dek) {
Ok(c) => c,
Err(_) => return String::new(),
};
let mut buffer = name.as_bytes().to_vec();
let aad = build_name_aad(parent_id);
let tag = cipher
.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer)
.expect("Name encryption");
let tag = match cipher.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer) {
Ok(t) => t,
Err(_) => return String::new(),
};
let mut combined = Vec::with_capacity(12 + 16 + buffer.len());
combined.extend_from_slice(&nonce_bytes);
combined.extend_from_slice(tag.as_slice());
@@ -221,9 +302,9 @@ pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
}
/// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM.
/// Prüft primär die kryptografische Bindung an parent_id; bietet transparenten Fallback
/// auf die statische AAD für ältere Container (Abwärtskompatibilität).
pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option<String> {
/// Prüft primär die kryptografische Bindung an parent_id; bietet optional Fallback
/// auf die statische AAD für ältere Container, wenn `allow_legacy` aktiv ist (S-10).
pub fn decrypt_node_name_ext(dek: &[u8; 32], parent_id: i64, stored: &str, allow_legacy: bool) -> Option<String> {
// Abwärtskompatibilität für alte v0.2.0 $h$<nonce>$<tag>$<ct> Namen
if let Some(rest) = stored.strip_prefix("$h$") {
let parts: Vec<&str> = rest.split('$').collect();
@@ -250,18 +331,20 @@ pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option
return String::from_utf8(buffer).ok();
}
// 2. Fallback: Statische AAD für echte Legacy-Dateinamen
let mut buffer_legacy = ct_bytes;
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(&nonce_bytes),
b"SANCTUM_NODE_NAME",
&mut buffer_legacy,
Tag::from_slice(&tag_bytes),
)
.is_ok()
{
return String::from_utf8(buffer_legacy).ok();
// 2. Fallback: Statische AAD nur wenn explizit aktiviert (S-10)
if allow_legacy {
let mut buffer_legacy = ct_bytes;
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(&nonce_bytes),
b"SANCTUM_NODE_NAME",
&mut buffer_legacy,
Tag::from_slice(&tag_bytes),
)
.is_ok()
{
return String::from_utf8(buffer_legacy).ok();
}
}
}
}
@@ -293,18 +376,20 @@ pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option
return String::from_utf8(buffer).ok();
}
// 2. Fallback: Alte statische AAD für bestehende Container
let mut buffer_legacy = ct.to_vec();
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(nonce),
b"SANCTUM_NODE_NAME",
&mut buffer_legacy,
Tag::from_slice(tag),
)
.is_ok()
{
return String::from_utf8(buffer_legacy).ok();
// 2. Fallback: Alte statische AAD nur wenn explizit aktiviert (S-10)
if allow_legacy {
let mut buffer_legacy = ct.to_vec();
if cipher
.decrypt_in_place_detached(
Nonce::from_slice(nonce),
b"SANCTUM_NODE_NAME",
&mut buffer_legacy,
Tag::from_slice(tag),
)
.is_ok()
{
return String::from_utf8(buffer_legacy).ok();
}
}
}
}
@@ -313,6 +398,10 @@ pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option
None
}
pub fn decrypt_node_name(dek: &[u8; 32], parent_id: i64, stored: &str) -> Option<String> {
decrypt_node_name_ext(dek, parent_id, stored, allows_legacy_names())
}
/// Kodiert den 32-Byte (256-Bit) DEK in eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase (englisch) mit 8-Bit Checksumme.
pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result<String> {
let mnemonic = bip39::Mnemonic::from_entropy(dek)
@@ -545,7 +634,9 @@ pub fn decrypt_chunk(
if payload.len() < 4 {
bail!("LZ4-Chunk beschädigt: Payload zu kurz für Längen-Präfix");
}
let uncompressed_size = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize;
let mut size_bytes = [0u8; 4];
size_bytes.copy_from_slice(&payload[0..4]);
let uncompressed_size = u32::from_le_bytes(size_bytes) as usize;
if uncompressed_size > CHUNK_SIZE {
bail!(
"LZ4-Dekomprimierungsfehler: Decompression-Bomb Schutz ausgelöst (angeforderte Größe {} Bytes > Limit {} Bytes)",
@@ -574,8 +665,8 @@ mod tests {
let password = "SuperSecretMasterPassword123!";
let salt = generate_salt();
let params = KdfParams {
memory_cost: 1024, // Schneller für Unit-Test
time_cost: 1,
memory_cost: MIN_MEMORY_COST_KIB,
time_cost: MIN_TIME_COST,
parallelism: 1,
};
@@ -787,8 +878,14 @@ mod tests {
hex::encode(static_tag),
hex::encode(&static_buf)
);
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format).expect("Decrypt legacy static AAD name");
// Standardmäßig muss Legacy-AAD abgewiesen werden (S-07)
assert!(decrypt_node_name(&dek, parent_id, &legacy_static_format).is_none());
// Mit aktiviertem Legacy-Flag darf es entschlüsselt werden
set_allow_legacy_names(true);
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format).expect("Decrypt legacy static AAD name with flag");
assert_eq!(decrypted_static, filename);
set_allow_legacy_names(false);
// Mit anderem DEK schlägt Entschlüsselung fehl
let other_dek = generate_dek();
@@ -807,7 +904,6 @@ mod tests {
let dek = generate_dek();
let enc_folder_a = encrypt_node_name(&dek, 10, "secrets.txt");
let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt");
// Gültige parent_ids entschlüsseln erfolgreich
assert_eq!(decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(), "secrets.txt");
assert_eq!(decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), "passwords.txt");
@@ -819,8 +915,9 @@ mod tests {
#[test]
fn test_model_a_slot_payloads() {
let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap();
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &KdfParams { memory_cost: 1024, time_cost: 1, parallelism: 1 }).unwrap();
let test_kdf = KdfParams { memory_cost: MIN_MEMORY_COST_KIB, time_cost: MIN_TIME_COST, parallelism: 1 };
let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &test_kdf).unwrap();
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &test_kdf).unwrap();
let dek_0 = generate_dek();
let dek_1 = generate_dek();
let carrier_node_id = 42i64;
+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)?;
}
+76 -14
View File
@@ -209,13 +209,27 @@ pub async fn mount_container(
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
}
// Prüfe, ob dek Dateien im Hidden Vault (Root 2) entschlüsseln kann
let is_hidden = {
let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default();
!children.is_empty()
let carrier_node_id = db.find_carrier_node_id()?.unwrap_or(0);
let (carrier_dek, final_carrier_node_id, vault_id) = if carrier_node_id > 0 {
let slot_id = crate::recovery::detect_recovery_key_slot(&db, &dek, meta.version)?;
if slot_id == 1 {
if !stealth {
println!(" [i] Der angegebene Notfallschlüssel gehört zum Hidden-Vault (Slot 1).");
println!(" Für den Zugriff auf die Trägerdatei wird das Passwort des Standard-Vaults benötigt.");
}
let decoy_pass = rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
.context("Fehler beim Einlesen des Standard-Vault Passworts")?;
let decoy_keys = meta.authenticate(&decoy_pass)
.ok_or_else(|| anyhow::anyhow!("Ungültiges Passwort für Standard-Vault."))?;
(Some(decoy_keys.dek().clone()), Some(carrier_node_id), 1)
} else {
(None, Some(carrier_node_id), 0)
}
} else {
(None, None, 0)
};
let vault_id = if is_hidden { 1 } else { 0 };
(dek, None, None, meta.version, vault_id)
(dek, carrier_dek, final_carrier_node_id, meta.version, vault_id)
}
};
@@ -402,12 +416,12 @@ pub async fn mount_container(
println!(" • Modus: WebDAV Userland-VFS");
}
}
println!(" • WebDAV-URL: http://127.0.0.1:{}/{}/ (Session-Token geschützt)", bound_port, session_token);
println!(" • WebDAV-URL: http://127.0.0.1:{}/<session-token>/ (lokal geschützt)", bound_port);
#[cfg(not(windows))]
{
println!(" • gio Befehl: gio mount dav://127.0.0.1:{}/{}/", bound_port, session_token);
println!(" • gio Befehl: gio mount dav://127.0.0.1:{}/<session-token>/", bound_port);
if let Some(mp) = mount_point {
println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/{}/ {}", bound_port, session_token, mp.display());
println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/<session-token>/ {}", bound_port, mp.display());
}
}
if let Some(secs) = idle_timeout {
@@ -602,15 +616,63 @@ pub async fn mount_container(
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green(""));
println!();
}
drop(db);
// S-10: Verifikation und Bereinigung von temporären SQLite WAL- und SHM-Dateien
let base_os = container_path.as_os_str().to_os_string();
let mut wal = base_os.clone();
wal.push("-wal");
let _ = std::fs::remove_file(wal);
let mut shm = base_os;
shm.push("-shm");
let _ = std::fs::remove_file(shm);
Ok(())
}
/// Überprüft, ob ein Host-Header auf Loopback (127.0.0.1, localhost, [::1]) zeigt (Anti-DNS-Rebinding).
/// Überprüft, ob ein Host-Header exakt auf Loopback (127.0.0.1, localhost, [::1], ::1) zeigt (Anti-DNS-Rebinding & S-06).
/// Striktes Parsing von Host und Port ohne anfällige starts_with-Präfixprüfungen.
pub fn is_loopback_host(host_str: &str) -> bool {
let host_lower = host_str.to_ascii_lowercase();
host_lower.starts_with("127.0.0.1")
|| host_lower.starts_with("localhost")
|| host_lower.starts_with("[::1]")
let host_trimmed = host_str.trim().to_ascii_lowercase();
if host_trimmed.is_empty() {
return false;
}
if host_trimmed == "::1" {
return true;
}
let host_part = if host_trimmed.starts_with('[') {
// IPv6 bracketed: [::1] oder [::1]:port
if let Some(bracket_end) = host_trimmed.find(']') {
let inside = &host_trimmed[1..bracket_end];
let after = &host_trimmed[bracket_end + 1..];
if !after.is_empty() {
if !after.starts_with(':') {
return false;
}
if after[1..].parse::<u16>().is_err() {
return false;
}
}
inside
} else {
return false;
}
} else {
// IPv4 oder Hostname: localhost, localhost:8443, 127.0.0.1, 127.0.0.1:8443
if let Some((h, p)) = host_trimmed.rsplit_once(':') {
if p.parse::<u16>().is_ok() {
h
} else {
return false;
}
} else {
&host_trimmed
}
};
matches!(host_part, "127.0.0.1" | "localhost" | "::1")
}
/// Maximale Anzahl gleichzeitiger Verbindungen zum lokalen WebDAV-Endpunkt (Schutz gegen Socket-Exhaustion).
+38 -13
View File
@@ -6,8 +6,8 @@ use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::crypto::{
derive_kek, generate_dummy_slot, mnemonic_to_dek, wrap_slot0_payload,
wrap_slot1_payload, KdfParams, FORMAT_VERSION,
decrypt_chunk, derive_kek, generate_dummy_slot, mnemonic_to_dek, validate_password,
wrap_slot0_payload, wrap_slot1_payload, KdfParams, FORMAT_VERSION,
};
use crate::storage::{ContainerMeta, Database, SlotMeta};
@@ -222,6 +222,30 @@ pub fn restore_header_backup(container_path: &Path, backup_path: &Path) -> Resul
Ok(())
}
/// Ermittelt automatisch, ob ein Notfallschlüssel zu Slot 0 (Standard/Decoy) oder Slot 1 (Hidden Vault) gehört (S-05).
pub fn detect_recovery_key_slot(db: &Database, dek: &[u8; 32], version: u32) -> Result<u32> {
if let Ok(Some(carrier_id)) = db.find_carrier_node_id() {
if let Ok(Some(chunk0)) = db.read_chunk(carrier_id, 0) {
let is_dek0 = decrypt_chunk(
dek,
carrier_id,
0,
&chunk0.ciphertext,
&chunk0.nonce,
&chunk0.tag,
version,
)
.is_ok();
if is_dek0 {
return Ok(0);
} else {
return Ok(1);
}
}
}
Ok(0)
}
/// Rekonstruiert einen Slot des Container-Headers mithilfe des 24-Wort BIP-39 Notfallschlüssels
/// und initialisiert ein neues Master-Passwort für den entsprechenden Slot (0 = Decoy, 1 = Hidden Vault).
pub fn restore_slot_from_recovery_key(
@@ -229,14 +253,13 @@ pub fn restore_slot_from_recovery_key(
recovery_key: &str,
new_password: &str,
target_slot_id: u32,
dek_0: Option<&[u8; 32]>,
) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
}
if new_password.trim().is_empty() {
bail!("Das neue Master-Passwort darf nicht leer sein.");
}
validate_password(new_password)?;
// 1. DEK aus 24-Wort-Phrase dekodieren & validieren
let dek = mnemonic_to_dek(recovery_key)
@@ -258,9 +281,11 @@ pub fn restore_slot_from_recovery_key(
let mut existing_slots = db.read_slots().unwrap_or_default();
let new_slot = if target_slot_id == 1 {
// Für Slot 1 wird DEK_0 benötigt (z. B. aus vorhandenem Slot 0)
let dek_0_bytes = [0u8; 32];
let (wrapped, nonce, tag) = wrap_slot1_payload(&kek, &dek, &dek_0_bytes, carrier_node_id)?;
// Für Slot 1 wird der echte DEK_0 benötigt (S-05)
let dek_0_bytes = dek_0.ok_or_else(|| {
anyhow::anyhow!("Für die Wiederherstellung von Slot 1 (Hidden Vault) wird der DEK von Slot 0 (Standard-Vault) benötigt.")
})?;
let (wrapped, nonce, tag) = wrap_slot1_payload(&kek, &dek, dek_0_bytes, carrier_node_id)?;
SlotMeta {
slot_id: 1,
version: FORMAT_VERSION,
@@ -333,7 +358,7 @@ pub fn restore_header_from_recovery_key(
recovery_key: &str,
new_password: &str,
) -> Result<()> {
restore_slot_from_recovery_key(container_path, recovery_key, new_password, 0)
restore_slot_from_recovery_key(container_path, recovery_key, new_password, 0, None)
}
#[cfg(test)]
@@ -358,8 +383,8 @@ mod tests {
let password = "SuperSecretPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
time_cost: crate::crypto::MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
@@ -413,8 +438,8 @@ mod tests {
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
time_cost: crate::crypto::MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek(old_password, &salt, &kdf_params).unwrap();
+199 -16
View File
@@ -9,7 +9,8 @@ use rusqlite::{params, Connection, OptionalExtension};
use crate::crypto::{
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,
validate_kdf_params, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2,
MAGIC_BYTES,
};
use zeroize::Zeroizing;
@@ -177,11 +178,79 @@ impl Database {
return Err(e.into());
}
};
// S-04: Untrusted Container & Fremddatei-Schutz.
// Bevor Pragmas, VACUUM oder Schema-Upgrades ausgeführt werden:
// Wenn Tabellen vorhanden sind, MUSS die 'meta'-Tabelle existieren und die Sanctum-Magic stimmen.
let table_count: i64 = conn
.query_row(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';",
[],
|r| r.get(0),
)
.unwrap_or(0);
if table_count > 0 {
let has_sanctum_schema: bool = {
let has_meta: bool = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='meta' LIMIT 1;",
[],
|_| Ok(()),
)
.optional()?
.is_some();
if !has_meta {
false
} else {
let magic_opt: Option<Option<Vec<u8>>> = conn
.query_row("SELECT magic FROM meta LIMIT 1;", [], |r| r.get(0))
.optional()?;
match magic_opt {
Some(Some(magic)) => magic.as_slice() == MAGIC_BYTES.as_slice(),
Some(None) => false,
None => {
// Die meta-Tabelle existiert, ist aber leer (z. B. zerstörter Header während einer Wiederherstellung).
// Prüfe, ob die typische Sanctum-Tabellenstruktur (nodes, chunks) vorhanden ist,
// um Fremddatenbanken weiterhin strikt abzuweisen.
let has_nodes: bool = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='nodes' LIMIT 1;",
[],
|_| Ok(()),
)
.optional()?
.is_some();
let has_chunks: bool = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='chunks' LIMIT 1;",
[],
|_| Ok(()),
)
.optional()?
.is_some();
has_nodes && has_chunks
}
}
}
};
if !has_sanctum_schema {
bail!(
"Die angegebene Datei '{}' ist kein gültiger Sanctum-Container (ungültige Signatur oder Metadaten fehlen). Datei wurde nicht modifiziert.",
path_ref.display()
);
}
}
let db = Self {
conn: Arc::new(Mutex::new(conn)),
};
db.init_pragmas()?;
db.ensure_schema_upgrades()?;
if table_count > 0 {
db.ensure_schema_upgrades()?;
}
Ok(db)
}
@@ -204,10 +273,16 @@ impl Database {
/// Authentifiziert ein Master-Passwort gegen den Container in konstanter Zeit.
pub fn authenticate_password(&self, password: &str) -> Result<Option<UnlockedKeys>> {
let meta = self.read_meta()?;
Ok(meta.authenticate(password))
let res = meta.authenticate(password);
if let Some(ref keys) = res {
if let Some(cid) = keys.carrier_node_id() {
let _ = self.mark_carrier_node_id(cid);
}
}
Ok(res)
}
/// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalte slot_id, auto_vacuum) durch.
/// 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);
@@ -219,6 +294,9 @@ impl Database {
// Spalte slot_id in meta (falls aus v1 migriert)
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", []);
Ok(())
}
@@ -271,16 +349,23 @@ impl Database {
Ok(count as usize)
}
/// Sucht nach einem existierenden Carrier-Knoten im Decoy-Wurzelverzeichnis (parent_id = 1, is_dir = 0).
/// 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 parent_id = 1 AND is_dir = 0 ORDER BY id ASC LIMIT 1",
"SELECT id FROM nodes WHERE is_carrier = 1 LIMIT 1",
)?;
let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?;
Ok(id)
}
/// Markiert einen existierenden Knoten explizit als Carrier.
pub fn mark_carrier_node_id(&self, id: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("UPDATE nodes SET is_carrier = 1 WHERE id = ?1", [id])?;
Ok(())
}
/// Prüft, ob ein Knoten (z. B. der Carrier-Knoten) ein Nachfahre (direkt oder indirekt) eines Verzeichnisses ist.
pub fn is_descendant_of(&self, node_id: i64, ancestor_id: i64) -> Result<bool> {
if node_id == ancestor_id {
@@ -374,6 +459,7 @@ impl Database {
size INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
is_carrier INTEGER NOT NULL DEFAULT 0,
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;
@@ -430,10 +516,10 @@ impl Database {
dek_1,
)) = carrier_config
{
// Trägerdatei in nodes (parent_id = 1, Decoy Root) anlegen
// Trägerdatei in nodes (parent_id = 1, Decoy Root) anlegen (is_carrier = 1)
conn.execute(
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at)
VALUES (1, ?1, 0, ?2, ?3, ?4)",
"INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at, is_carrier)
VALUES (1, ?1, 0, ?2, ?3, ?4, 1)",
params![c_name, c_size as i64, now, now],
)?;
let c_id = conn.last_insert_rowid();
@@ -589,6 +675,7 @@ impl Database {
size INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
is_carrier INTEGER NOT NULL DEFAULT 0,
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;
@@ -701,19 +788,88 @@ impl Database {
let tag_vec: Vec<u8> = row.get(6)?;
let mut kdf_salt = [0u8; 16];
if salt_vec.len() == 16 {
kdf_salt.copy_from_slice(&salt_vec);
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()),
)),
));
}
kdf_salt.copy_from_slice(&salt_vec);
let mut header_nonce = [0u8; 12];
if nonce_vec.len() == 12 {
header_nonce.copy_from_slice(&nonce_vec);
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()),
)),
));
}
header_nonce.copy_from_slice(&nonce_vec);
let mut header_tag = [0u8; 16];
if tag_vec.len() == 16 {
header_tag.copy_from_slice(&tag_vec);
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()),
)),
));
}
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()),
)),
));
}
} 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()),
)),
));
}
}
let kdf_params: KdfParams = serde_json::from_str(&params_str).unwrap_or_default();
let kdf_params: KdfParams = serde_json::from_str(&params_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),
)),
))?;
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),
)),
))?;
Ok(SlotMeta {
slot_id,
@@ -1083,8 +1239,32 @@ impl Database {
Ok(())
}
/// Fail-Closed Carrier Guard: Verhindert, dass mutierende Dateioperationen
/// (Löschen, Truncate, Umbenennen, VFS-Writes) versehentlich oder böswillig
/// die Alibi-Trägerdatei im Decoy-Vault beschädigen oder zerstören (S-03).
pub fn assert_not_carrier(&self, node_id: i64) -> Result<()> {
if let Ok(Some(carrier_id)) = self.find_carrier_node_id() {
if node_id == carrier_id {
bail!(
"Operation auf Alibi-Trägerdatei (Carrier, Node-ID {}) ist strikt untersagt (Carrier-Schutz).",
node_id
);
}
}
Ok(())
}
/// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar.
pub fn delete_node(&self, id: i64) -> Result<()> {
self.assert_not_carrier(id)?;
// Verhindere auch das Löschen eines Verzeichnisses, das die Trägerdatei enthält
if let Ok(Some(carrier_id)) = self.find_carrier_node_id() {
if self.is_descendant_of(carrier_id, id).unwrap_or(false) {
bail!("Verzeichnis enthält die Trägerdatei (Node-ID {}) und darf nicht gelöscht werden.", carrier_id);
}
}
// 1. Shredde Chunks dieses Knotens mit kryptografischem Zufallsrauschen
let _ = self.shred_chunks_for_node(id);
@@ -1118,6 +1298,7 @@ impl Database {
vault_id: u32,
dek: &[u8; 32],
) -> Result<()> {
self.assert_not_carrier(id)?;
let now = current_timestamp();
let conn = self.conn.lock().unwrap();
let stored_name = if vault_id == 1 {
@@ -1212,6 +1393,7 @@ impl Database {
new_size: u64,
modified_at: u64,
) -> Result<()> {
self.assert_not_carrier(node_id)?;
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
tx.execute(
@@ -1240,6 +1422,7 @@ impl Database {
/// Schneidet überzählige Chunks ab (z. B. beim Truncate oder Überschreiben mit kleinerer Datei)
/// und shreddert die abzuschneidenden Chunks vorher mit kryptografischem Zufallsrauschen.
pub fn truncate_chunks_after(&self, node_id: i64, max_chunk_index: u32) -> Result<()> {
self.assert_not_carrier(node_id)?;
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
{
+144 -2
View File
@@ -6,11 +6,103 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use sha2::{Digest, Sha256};
use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE};
use crate::storage::{Database, NodeRecord};
use crate::ui;
use crate::vfs::is_leak_file;
/// Validiert Knotennamen gegen Path-Traversal (CWE-22) und reservierte Windows-Gerätenamen (S-08).
pub fn validate_node_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("Dateiname darf nicht leer sein.");
}
if name == "." || name == ".." {
bail!("Ungültiger Dateiname (Verzeichnisreferenz verboten): '{}'", name);
}
if name.contains('/') || name.contains('\\') || name.contains('\0') {
bail!(
"Dateiname enthält unzulässige Trennzeichen oder Null-Bytes: '{}'",
name
);
}
for c in name.chars() {
if (c as u32) < 0x20 {
bail!("Dateiname enthält Steuerzeichen: '{}'", name);
}
}
// Windows reservierte Gerätenamen (CON, PRN, AUX, NUL, COM1..9, LPT1..9)
let base_name = if let Some(dot_idx) = name.find('.') {
&name[..dot_idx]
} else {
name
};
let base_upper = base_name.to_ascii_uppercase();
let reserved = [
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
if reserved.contains(&base_upper.as_str()) {
bail!(
"Dateiname '{}' kollidiert mit einem reservierten Windows-Gerätenamen.",
name
);
}
Ok(())
}
/// Berechnet den SHA-256 Hash einer lokalen Datei für verlässliche Checksummen-Vergleiche (S-10).
fn calc_local_file_sha256(path: &Path) -> Result<String> {
let mut file = File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let n = file.read(&mut buffer)?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
/// Berechnet den SHA-256 Hash der entschlüsselten Nutzdaten eines Vault-Knotens (S-10).
fn calc_vault_node_sha256(
db: &Database,
node: &NodeRecord,
dek: &[u8; 32],
version: u32,
) -> Result<String> {
let mut hasher = Sha256::new();
let total_chunks = if node.size == 0 {
0
} else {
((node.size - 1) / CHUNK_SIZE as u64 + 1) as u32
};
for idx in 0..total_chunks {
let record = db
.read_chunk(node.id, idx)?
.ok_or_else(|| anyhow::anyhow!("Fehlender Chunk {} für Knoten {}", idx, node.id))?;
let decrypted = decrypt_chunk(
dek,
node.id,
idx,
&record.ciphertext,
&record.nonce,
&record.tag,
version,
)?;
hasher.update(&decrypted);
}
Ok(hex::encode(hasher.finalize()))
}
/// Synchronisationsrichtung
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncDirection {
@@ -180,6 +272,8 @@ pub fn sync_single_file_to_vault(
checksum: bool,
dry_run: bool,
) -> Result<FileTransferResult> {
validate_node_name(file_name)?;
let meta = fs::metadata(local_path)
.with_context(|| format!("Konnte Metadaten für '{}' nicht lesen", local_path.display()))?;
let local_size = meta.len();
@@ -198,10 +292,22 @@ pub fn sync_single_file_to_vault(
bail!("Pfad-Konflikt: '{}' existiert im Tresor als Ordner", file_name);
}
// Fast Check: Wenn Größe und mtime identisch sind, überspringen
// Fast Check: Wenn Größe und mtime identisch sind, überspringen (ohne --checksum)
if !checksum && node.size == local_size && node.modified_at == local_mtime {
return Ok(FileTransferResult::Skipped { size: local_size });
}
// Deep Check mit --checksum: Echter kryptografischer SHA-256 Hashvergleich (S-10)
if checksum && node.size == local_size {
if let (Ok(local_hash), Ok(vault_hash)) = (
calc_local_file_sha256(local_path),
calc_vault_node_sha256(db, node, dek, version),
) {
if local_hash == vault_hash {
return Ok(FileTransferResult::Skipped { size: local_size });
}
}
}
}
if dry_run {
@@ -264,6 +370,8 @@ pub fn sync_single_file_to_host(
checksum: bool,
dry_run: bool,
) -> Result<FileTransferResult> {
validate_node_name(&node.name)?;
if local_path.exists() {
if let Ok(meta) = fs::metadata(local_path) {
let local_size = meta.len();
@@ -277,6 +385,18 @@ pub fn sync_single_file_to_host(
if !checksum && local_size == node.size && local_mtime == node.modified_at {
return Ok(FileTransferResult::Skipped { size: node.size });
}
// Deep Check mit --checksum: Echter kryptografischer SHA-256 Hashvergleich (S-10)
if checksum && local_size == node.size {
if let (Ok(local_hash), Ok(vault_hash)) = (
calc_local_file_sha256(local_path),
calc_vault_node_sha256(db, node, dek, version),
) {
if local_hash == vault_hash {
return Ok(FileTransferResult::Skipped { size: node.size });
}
}
}
}
}
@@ -457,6 +577,7 @@ fn collect_and_push_dir(
let entry = entry?;
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
validate_node_name(&file_name)?;
let rel_path = path
.strip_prefix(base_dir)
@@ -545,7 +666,7 @@ fn collect_and_push_dir(
Ok(())
}
fn delete_orphans_in_vault(
pub fn delete_orphans_in_vault(
db: &Database,
vault_id: u32,
dek: &[u8; 32],
@@ -556,9 +677,15 @@ fn delete_orphans_in_vault(
quiet: bool,
stats: &mut SyncStats,
) -> Result<()> {
let carrier_id = db.find_carrier_node_id()?.unwrap_or(0);
let children = db.list_children_in_vault(current_vault_id, vault_id, dek)?;
for child in children {
// S-03 Carrier Guard: Trägerdatei und übergeordnete Verzeichnisse niemals löschen!
if carrier_id > 0 && (child.id == carrier_id || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) {
continue;
}
let child_rel = if prefix_rel.is_empty() {
child.name.clone()
} else {
@@ -704,6 +831,8 @@ fn collect_and_pull_dir(
let children = db.list_children_in_vault(vault_node_id, vault_id, dek)?;
for child in children {
validate_node_name(&child.name)?;
let child_rel = if rel_prefix.is_empty() {
child.name.clone()
} else {
@@ -714,9 +843,22 @@ fn collect_and_pull_dir(
continue;
}
// S-08 Path Traversal Guard:
// Prüfe Komponenten des relativen Pfads und stelle sicher, dass der Pfad nicht ausbricht
for comp in Path::new(&child_rel).components() {
match comp {
std::path::Component::Normal(_) => {}
_ => bail!("Path traversal Versuch erkannt in relativem Pfad: '{}'", child_rel),
}
}
vault_relative_paths.insert(child_rel.clone());
let local_child_path = local_target_base.join(&child_rel.replace('/', "\\"));
if !local_child_path.starts_with(local_target_base) {
bail!("Path traversal Versuch erkannt: '{}' bricht aus Zielverzeichnis aus", child_rel);
}
if child.is_dir {
if !options.dry_run {
fs::create_dir_all(&local_child_path)?;
+98 -29
View File
@@ -1,4 +1,4 @@
use std::fs::{self, File};
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -16,7 +16,10 @@ pub const TARGET_ASSET_NAME: &str = "sanctum.exe";
#[cfg(not(windows))]
pub const TARGET_ASSET_NAME: &str = "sanctum";
pub const OFFICIAL_UPGRADE_URL: &str = "https://gitea.pansi.eu";
pub const CHECKSUM_ASSET_NAME: &str = "SHA256SUMS.txt";
pub const CHECKSUM_SIG_ASSET_NAME: &str = "SHA256SUMS.txt.minisig";
pub const SANCTUM_RELEASE_PUBKEY: &str = "RWSsVphPgr8157M9rTPkWDw3c0qIjc7xi28Gmw+cIWbMipOy4L6ToJEU";
/// Repräsentiert ein Asset in einem Gitea-Release
#[derive(Debug, Deserialize, Clone)]
@@ -46,6 +49,7 @@ pub struct UpgradeOptions {
pub yes: bool,
pub force: bool,
pub base_url: String,
pub insecure_url: bool,
}
impl Default for UpgradeOptions {
@@ -54,11 +58,28 @@ impl Default for UpgradeOptions {
check_only: false,
yes: false,
force: false,
base_url: "https://gitea.pansi.eu".to_string(),
base_url: OFFICIAL_UPGRADE_URL.to_string(),
insecure_url: false,
}
}
}
/// Überprüft eine Minisign-Signatur für gegebene Binärdaten mit dem angegebenen Minisign Public Key (S-02).
pub fn verify_minisign_signature(
data: &[u8],
sig_str: &str,
pubkey_str: &str,
) -> Result<()> {
let pubkey = minisign_verify::PublicKey::from_base64(pubkey_str)
.map_err(|e| anyhow::anyhow!("Ungültiger öffentlicher Minisign-Schlüssel: {e}"))?;
let signature = minisign_verify::Signature::decode(sig_str)
.map_err(|e| anyhow::anyhow!("Ungültiges Minisign-Signaturformat: {e}"))?;
pubkey
.verify(data, &signature, true)
.map_err(|e| anyhow::anyhow!("Minisign-Signaturprüfung FEHLGESCHLAGEN: {e}. Das Release-Manifest ist nicht vertrauenswürdig!"))?;
Ok(())
}
/// Erkannter Paketmanager
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PackageManager {
@@ -198,19 +219,11 @@ pub fn download_and_verify_binary(
let mut reader = resp.into_reader();
let temp_dir = std::env::temp_dir();
let temp_file_path = temp_dir.join(format!(
"sanctum_update_{}_{}",
std::process::id(),
TARGET_ASSET_NAME
));
let mut temp_file = File::create(&temp_file_path).with_context(|| {
format!(
"Konnte temporäre Update-Datei '{}' nicht erstellen",
temp_file_path.display()
)
})?;
let mut temp_file = tempfile::Builder::new()
.prefix("sanctum_update_")
.suffix(if cfg!(windows) { ".exe" } else { "" })
.tempfile()
.context("Konnte temporäre Update-Datei nicht sicher erstellen")?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 64 * 1024]; // 64 KB Puffer
@@ -229,6 +242,7 @@ pub fn download_and_verify_binary(
}
temp_file
.as_file_mut()
.write_all(&buffer[..n])
.context("Fehler beim Schreiben in die temporäre Datei")?;
hasher.update(&buffer[..n]);
@@ -254,16 +268,16 @@ pub fn download_and_verify_binary(
println!();
temp_file
.as_file_mut()
.flush()
.context("Fehler beim Flush der temporären Datei")?;
drop(temp_file);
// SHA-256 Integritätsprüfung
let calculated_hash = hex::encode(hasher.finalize()).to_lowercase();
let expected_lower = expected_hash.to_lowercase();
if calculated_hash != expected_lower {
let _ = fs::remove_file(&temp_file_path);
// temp_file wird beim Drop automatisch gelöscht
bail!(
"Kryptografische Prüfsumme ungültig!\n Erwartet: {}\n Berechnet: {}\nDas Update wurde aus Sicherheitsgründen verworfen.",
expected_lower,
@@ -273,7 +287,11 @@ pub fn download_and_verify_binary(
println!(" • SHA-256: {} (verifiziert)", ui::green(&calculated_hash[..16]));
Ok(temp_file_path)
let (_, path) = temp_file
.keep()
.context("Konnte temporäre Datei nicht für Ersetzung persistieren")?;
Ok(path)
}
/// Führt den In-Place-Austausch des laufenden Binaries durch.
@@ -284,19 +302,18 @@ pub fn apply_upgrade(temp_binary: &Path) -> Result<PathBuf> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(temp_binary)
.context("Konnte Metadaten der Update-Datei nicht lesen")?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(temp_binary, perms)
.context("Konnte Ausführungsrechte (chmod +x / 0755) nicht setzen")?;
if let Ok(meta) = fs::metadata(temp_binary) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = fs::set_permissions(temp_binary, perms);
}
}
self_replace::self_replace(temp_binary)
.context("In-Place-Ersetzung der laufenden Anwendungsdatei fehlgeschlagen")?;
let replace_res = self_replace::self_replace(temp_binary);
let _ = fs::remove_file(temp_binary);
replace_res.context("In-Place-Ersetzung der laufenden Anwendungsdatei fehlgeschlagen")?;
Ok(current_exe)
}
@@ -311,6 +328,37 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
println!(" • Server: {}", options.base_url);
println!();
// S-02: Prüfung auf abweichende Server-URL
let is_official_server = options.base_url.trim_end_matches('/') == OFFICIAL_UPGRADE_URL.trim_end_matches('/');
if !is_official_server {
if !options.insecure_url {
bail!(
"Sicherheitsverstoß: Abweichende Server-URL '{}' ist nicht vertrauenswürdig.\n\
Aus Sicherheitsgründen sind Drittanbieter-URLs standardmäßig gesperrt.\n\
Falls beabsichtigt, verwenden Sie das Flag '--insecure-url'.",
options.base_url
);
}
// Auch mit --insecure-url ist zwingend eine manuelle Bestätigung erforderlich (nicht mit -y überschreibbar!)
println!(
" {} WARNUNG: Sie aktualisieren von einem inoffiziellen Server: {}",
ui::red("[SICHERHEITSWARNUNG]"),
options.base_url
);
println!(" Offizieller Server ist: {}", OFFICIAL_UPGRADE_URL);
print!(" Möchten Sie dieses ungesicherte Update WIRKLICH fortsetzen? [Tippen Sie 'JA' zur Bestätigung]: ");
let _ = std::io::stdout().flush();
let mut confirm = String::new();
std::io::stdin()
.read_line(&mut confirm)
.context("Fehler beim Einlesen der Bestätigung")?;
if confirm.trim() != "JA" {
bail!("Upgrade von Drittanbieter-URL durch Benutzer abgebrochen.");
}
println!();
}
// 1. Paketmanager-Prüfung
if let Some(pm) = detect_package_manager() {
println!(
@@ -373,7 +421,7 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
return Ok(());
}
// 3. Ziel-Asset und Checksumme identifizieren
// 3. Ziel-Asset, Checksumme und Minisign-Signatur identifizieren
let target_asset = release
.assets
.iter()
@@ -398,9 +446,30 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
)
})?;
// 4. Prüfsummen herunterladen und extrahieren
let sig_asset = release
.assets
.iter()
.find(|a| a.name.eq_ignore_ascii_case(CHECKSUM_SIG_ASSET_NAME))
.ok_or_else(|| {
anyhow::anyhow!(
"Im Release '{}' wurde keine Minisign-Signatur ('{}') gefunden! Unsignierte Updates werden verweigert.",
remote_tag,
CHECKSUM_SIG_ASSET_NAME
)
})?;
// 4. Prüfsummen und Signatur herunterladen und kryptografisch prüfen (S-02)
println!(" {} Lade Prüfsummen ({}) herunter ...", ui::dim("[-]"), CHECKSUM_ASSET_NAME);
let sums_content = fetch_text(&checksum_asset.browser_download_url)?;
println!(" {} Lade Minisign-Signatur ({}) herunter ...", ui::dim("[-]"), CHECKSUM_SIG_ASSET_NAME);
let sig_content = fetch_text(&sig_asset.browser_download_url)?;
println!(" {} Verifiziere Minisign-Signatur gegen eingebetteten Herstellerschlüssel ...", ui::dim("[-]"));
verify_minisign_signature(sums_content.as_bytes(), &sig_content, SANCTUM_RELEASE_PUBKEY)
.context("Minisign-Signaturprüfung für Prüfsummendatei FEHLGESCHLAGEN! Release ist nicht vertrauenswürdig.")?;
println!(" • Signatur: {} (Minisign Ed25519)", ui::green("Gültig"));
let expected_hash = parse_checksum_for_asset(&sums_content, TARGET_ASSET_NAME).ok_or_else(|| {
anyhow::anyhow!(
"Prüfsummendatei enthält keinen SHA-256 Eintrag für '{}'!",
+4 -4
View File
@@ -310,8 +310,8 @@ mod tests {
let password = "HealthyTestPassword123!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
time_cost: crate::crypto::MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
@@ -362,8 +362,8 @@ mod tests {
let password = "BitrotTestPassword123!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
time_cost: crate::crypto::MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
+3 -3
View File
@@ -1052,11 +1052,11 @@ mod tests {
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
memory_cost: crate::crypto::MIN_MEMORY_COST_KIB,
time_cost: crate::crypto::MIN_TIME_COST,
parallelism: 1,
};
let kek = derive_kek("testpwd", &salt, &kdf_params).unwrap();
let kek = derive_kek("TestMasterPassword123!", &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag)