feat(recovery): implement header backup/restore, BIP-39 recovery key, and integrity verification
This commit is contained in:
+280
-41
@@ -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)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user