feat(recovery): implement header backup/restore, BIP-39 recovery key, and integrity verification

This commit is contained in:
2026-09-08 09:49:08 +02:00
parent 2d14c64c3e
commit 1c8a860184
11 changed files with 1451 additions and 57 deletions
+280 -41
View File
@@ -5,11 +5,16 @@ use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
use sanctum::crypto::{
derive_kek, generate_dek, generate_salt, unwrap_dek, wrap_dek, KdfParams,
dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek, unwrap_dek,
wrap_dek, KdfParams, FORMAT_VERSION,
};
use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth};
use sanctum::recovery::{
export_header_backup, restore_header_backup, restore_header_from_recovery_key,
};
use sanctum::mount::{format_drive, mount_container, unmount_drive};
use sanctum::storage::Database;
use sanctum::ui;
use sanctum::verify::verify_container;
#[derive(Parser)]
#[command(name = "sanctum")]
@@ -43,6 +48,10 @@ enum Commands {
/// Optionaler TCP-Port für den lokalen WebDAV-Server (Standard: 8443)
#[arg(long)]
port: Option<u16>,
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (umgeht Passwortabfrage)
#[arg(long)]
recovery_key: Option<String>,
},
/// Trennt ein eingebundenes Netzlaufwerk manuell
@@ -57,6 +66,54 @@ enum Commands {
/// Pfad zur .sanctum Containerdatei
#[arg(short, long)]
path: PathBuf,
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (erlaubt Reset bei vergessenem Passwort)
#[arg(long)]
recovery_key: Option<String>,
},
/// Sichert den Container-Header in eine separate Backup-Datei (.sanctum.hdr)
BackupHeader {
/// Pfad zur .sanctum Containerdatei
#[arg(short, long)]
path: PathBuf,
/// Optionaler Ausgabepfad (Standard: <CONTAINER>.hdr)
#[arg(short, long)]
output: Option<PathBuf>,
},
/// Stellt den Container-Header aus einem Backup oder via 24-Wort Notfallschlüssel wieder her
RestoreHeader {
/// Pfad zur .sanctum Containerdatei
#[arg(short, long)]
path: PathBuf,
/// Pfad zur Header-Backup-Datei (.sanctum.hdr)
#[arg(long)]
header_file: Option<PathBuf>,
/// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort
#[arg(long)]
recovery_key: Option<String>,
},
/// Zeigt den 24-Wort BIP-39 Notfall-Wiederherstellungsschlüssel des Containers an
RecoveryKey {
/// Pfad zur .sanctum Containerdatei
#[arg(short, long)]
path: PathBuf,
},
/// Führt eine Integritätsprüfung (FSCK) und Bitrot-Erkennung auf dem Container durch
Verify {
/// Pfad zur .sanctum Containerdatei
#[arg(short, long)]
path: PathBuf,
/// Vollständige kryptografische AEAD-Prüfung aller Chunks
#[arg(long, default_value_t = true)]
full: bool,
},
}
@@ -126,24 +183,29 @@ fn handle_init(container_path: &Path) -> Result<()> {
db.checkpoint()
.context("Fehler beim finalen WAL-Checkpoint")?;
let recovery_phrase =
dek_to_mnemonic(&dek).context("Fehler beim Erzeugen der Notfallphrase")?;
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ ✔ Sanctum-Container erfolgreich initialisiert! │");
println!("└─────────────────────────────────────────────────────────────┘");
println!();
println!(" • Container: {}", container_path.display());
println!(" • Format: Version 1 (Magic: SANCTUM\\0)");
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
println!(" • KDF: Argon2id (M=64MB, T=3, P=4)");
println!(" • Cipher: AES-256-GCM (1-MB Chunks, AEAD Swap-Schutz)");
println!(" • Cipher: AES-256-GCM + LZ4-Kompression (1-MB Chunks, AEAD)");
println!();
println!(" Befehl zum Einbinden als Netzlaufwerk:");
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\" --drive S", container_path.display())));
println!();
ui::print_recovery_phrase_card(&recovery_phrase);
Ok(())
}
fn handle_passwd(container_path: &Path) -> Result<()> {
fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()> {
if !container_path.exists() {
bail!(
"Containerdatei '{}' existiert nicht.",
@@ -157,33 +219,40 @@ fn handle_passwd(container_path: &Path) -> Result<()> {
println!(" Container: {}", container_path.display());
println!();
let old_password = rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des aktuellen Passworts")?;
if old_password.trim().is_empty() {
bail!("Das aktuelle Master-Passwort darf nicht leer sein.");
}
println!();
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let meta = db
.read_meta()
.context("Konnte Container-Header nicht lesen")?;
let dek = 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
} else {
let old_password = rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des aktuellen Passworts")?;
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort...");
let old_kek = derive_kek(&old_password, &meta.kdf_salt, &meta.kdf_params)
.context("Schlüsselableitung fehlgeschlagen")?;
if old_password.trim().is_empty() {
bail!("Das aktuelle Master-Passwort darf nicht leer sein.");
}
let dek = unwrap_dek(
&old_kek,
&meta.wrapped_dek,
&meta.header_nonce,
&meta.header_tag,
)
.map_err(|_| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?;
println!();
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
let meta = db
.read_meta()
.context("Konnte Container-Header nicht lesen")?;
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort...");
let old_kek = derive_kek(&old_password, &meta.kdf_salt, &meta.kdf_params)
.context("Schlüsselableitung fehlgeschlagen")?;
unwrap_dek(
&old_kek,
&meta.wrapped_dek,
&meta.header_nonce,
&meta.header_tag,
)
.map_err(|_| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?
};
println!();
let new_password = rpassword::prompt_password("Neues Master-Passwort eingeben: ")
@@ -200,10 +269,6 @@ fn handle_passwd(container_path: &Path) -> Result<()> {
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
}
if old_password == new_password {
bail!("Das neue Master-Passwort muss sich vom aktuellen Passwort unterscheiden.");
}
println!();
ui::step(3, 4, "🔒", "Generiere frisches Salt & leite neuen KEK ab...");
let new_salt = generate_salt();
@@ -240,6 +305,154 @@ fn handle_passwd(container_path: &Path) -> Result<()> {
Ok(())
}
fn handle_backup_header(container_path: &Path, output_path: Option<&Path>) -> Result<()> {
let out = match output_path {
Some(p) => p.to_path_buf(),
None => {
let file_name = container_path
.file_name()
.unwrap_or_default()
.to_string_lossy();
container_path.with_file_name(format!("{file_name}.hdr"))
}
};
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — Container-Header sichern │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Container: {}", container_path.display());
println!(" Backup-Ziel: {}", out.display());
println!();
export_header_backup(container_path, &out)?;
println!("{}", ui::green("✔ Header-Backup erfolgreich exportiert!"));
println!();
println!(" Bewahren Sie diese Sicherungsdatei an einem sicheren Ort auf.");
println!(" Sie enthält alle KDF-Parameter und den verschlüsselten Master-DEK.");
println!();
Ok(())
}
fn handle_restore_header(
container_path: &Path,
header_file: Option<&Path>,
recovery_key: Option<&str>,
) -> Result<()> {
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — Container-Header wiederherstellen │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Container: {}", container_path.display());
println!();
if let Some(hdr) = header_file {
println!(" Verwende Backup-Datei: {}", hdr.display());
ui::step(1, 2, "📦", "Lese und validiere Header-Backup...");
restore_header_backup(container_path, hdr)?;
ui::step(2, 2, "💾", "Header in Container-Datenbank zurückgeschrieben!");
println!();
println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!"));
} else if let Some(key) = recovery_key {
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel...");
let new_password = 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.");
}
let confirm_password = rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
.context("Fehler beim Einlesen der Passwort-Bestätigung")?;
if new_password != confirm_password {
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
}
ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab...");
restore_header_from_recovery_key(container_path, key, &new_password)?;
ui::step(2, 2, "💾", "Header mit neuem Passwort neu synthetisiert!");
println!();
println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!"));
} else {
bail!("Bitte geben Sie entweder --header-file <PFAD> oder --recovery-key \"<24 WÖRTER>\" an.");
}
println!();
Ok(())
}
fn handle_recovery_key(container_path: &Path) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
}
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — 24-Wort Notfallschlüssel anzeigen │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Container: {}", container_path.display());
println!();
let password = rpassword::prompt_password("Master-Passwort eingeben: ")
.context("Fehler beim Einlesen des Passworts")?;
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
let kek = derive_kek(&password, &meta.kdf_salt, &meta.kdf_params)
.context("Schlüsselableitung fehlgeschlagen")?;
let dek = unwrap_dek(&kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag)
.map_err(|_| anyhow::anyhow!("Ungültiges Master-Passwort!"))?;
let phrase = dek_to_mnemonic(&dek)?;
ui::print_recovery_phrase_card(&phrase);
Ok(())
}
fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
}
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum — Container-Integritätsprüfung (FSCK) │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Container: {}", container_path.display());
println!();
let password = rpassword::prompt_password("Master-Passwort für Vollprüfung (Enter für Strukturprüfung): ")
.context("Fehler beim Einlesen des Passworts")?;
let dek = if !password.trim().is_empty() {
let db = Database::open(container_path).context("Konnte Container nicht öffnen")?;
let meta = db.read_meta().context("Konnte Header nicht lesen")?;
let kek = derive_kek(&password, &meta.kdf_salt, &meta.kdf_params)?;
match unwrap_dek(&kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag) {
Ok(d) => {
println!(" {} Master-Passwort verifiziert. Führe kryptografische AEAD-Vollprüfung durch...", ui::green(""));
Some(d)
}
Err(_) => {
println!(" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", ui::yellow("⚠️"));
None
}
}
} else {
println!(" {} Kein Passwort angegeben. Führe nur Strukturprüfung durch.", ui::dim(""));
None
};
let report = verify_container(container_path, dek.as_ref(), full)?;
ui::print_verification_report(&report);
if !report.is_healthy() {
std::process::exit(1);
}
Ok(())
}
async fn run() -> Result<()> {
let cli = Cli::parse();
@@ -247,16 +460,26 @@ async fn run() -> Result<()> {
Commands::Init { path } => {
handle_init(&path)?;
}
Commands::Mount { path, drive, port } => {
Commands::Mount {
path,
drive,
port,
recovery_key,
} => {
let drive_char = parse_drive_letter(&drive)?;
let prompt_text = format!(
"Master-Passwort für Container '{}' eingeben: ",
path.display()
);
let password = rpassword::prompt_password(prompt_text)
.context("Fehler beim Einlesen des Passworts")?;
let auth = if let Some(key) = recovery_key {
ContainerAuth::RecoveryKey(key)
} else {
let prompt_text = format!(
"Master-Passwort für Container '{}' eingeben: ",
path.display()
);
let password = rpassword::prompt_password(prompt_text)
.context("Fehler beim Einlesen des Passworts")?;
ContainerAuth::Password(password)
};
mount_container(&path, drive_char, port, &password).await?;
mount_container(&path, drive_char, port, auth).await?;
}
Commands::Unmount { drive } => {
let drive_char = parse_drive_letter(&drive)?;
@@ -267,8 +490,24 @@ async fn run() -> Result<()> {
println!("{}", ui::green("OK"));
println!("{} Laufwerk {} erfolgreich getrennt.", ui::green(""), drive_str);
}
Commands::Passwd { path } => {
handle_passwd(&path)?;
Commands::Passwd { path, recovery_key } => {
handle_passwd(&path, recovery_key.as_deref())?;
}
Commands::BackupHeader { path, output } => {
handle_backup_header(&path, output.as_deref())?;
}
Commands::RestoreHeader {
path,
header_file,
recovery_key,
} => {
handle_restore_header(&path, header_file.as_deref(), recovery_key.as_deref())?;
}
Commands::RecoveryKey { path } => {
handle_recovery_key(&path)?;
}
Commands::Verify { path, full } => {
handle_verify(&path, full)?;
}
}