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
Generated
+68
View File
@@ -136,6 +136,12 @@ dependencies = [
"password-hash",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -160,6 +166,27 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bip39"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
dependencies = [
"bitcoin_hashes",
"serde",
"unicode-normalization",
"zeroize",
]
[[package]]
name = "bitcoin_hashes"
version = "0.14.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2"
dependencies = [
"hex-conservative",
]
[[package]]
name = "bitflags"
version = "2.13.1"
@@ -596,6 +623,21 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-conservative"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08"
dependencies = [
"arrayvec",
]
[[package]]
name = "htmlescape"
version = "0.3.1"
@@ -1193,11 +1235,13 @@ dependencies = [
"aes-gcm",
"anyhow",
"argon2",
"bip39",
"bytes",
"clap",
"dav-server",
"dyn-clone",
"futures-util",
"hex",
"http-body-util",
"hyper",
"hyper-util",
@@ -1411,6 +1455,21 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.53.1"
@@ -1538,6 +1597,15 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "universal-hash"
version = "0.5.1"
+2
View File
@@ -34,6 +34,8 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dyn-clone = "1.0"
lz4_flex = "0.11"
bip39 = { version = "2.2", features = ["zeroize"] }
hex = "0.4"
[profile.release]
opt-level = 3
+71
View File
@@ -129,6 +129,37 @@ pub fn unwrap_dek(
Ok(dek)
}
/// 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)
.map_err(|e| anyhow::anyhow!("Fehler beim Erzeugen der BIP-39 Notfallphrase: {e}"))?;
Ok(mnemonic.to_string())
}
/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK.
/// Validiert dabei Wörter und die integrierte BIP-39 Prüfsumme.
pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
let cleaned = phrase
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ");
let mnemonic = bip39::Mnemonic::parse_normalized(&cleaned)
.map_err(|e| anyhow::anyhow!("Ungültige BIP-39 Notfallphrase (Wortfehler oder ungültige Prüfsumme): {e}"))?;
let entropy = mnemonic.to_entropy();
if entropy.len() != 32 {
bail!(
"Ungültige Entropielänge aus Mnemonic: erwartet 32 Bytes (24 Wörter), erhalten {}",
entropy.len()
);
}
let mut dek = Zeroizing::new([0u8; 32]);
dek.copy_from_slice(&entropy);
Ok(dek)
}
/// Erzeugt die 16-Byte Associated Data (AAD) für einen Chunk, um Swap-Angriffe zu verhindern:
/// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian).
#[inline]
@@ -342,4 +373,44 @@ mod tests {
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_bip39_recovery_phrase_roundtrip() {
let dek = generate_dek();
let mnemonic_str = dek_to_mnemonic(&dek).expect("Generate mnemonic");
let words: Vec<&str> = mnemonic_str.split_whitespace().collect();
assert_eq!(words.len(), 24, "Mnemonic must have exactly 24 words");
let recovered_dek = mnemonic_to_dek(&mnemonic_str).expect("Recover DEK");
assert_eq!(*dek, *recovered_dek, "Recovered DEK must match original DEK");
// Whitespace-Toleranz (z. B. doppelte Leerzeichen, Zeilenumbrüche)
let messy_phrase = format!(" {} \n\t {} ", words[0..12].join(" "), words[12..24].join(" \n "));
let recovered_messy = mnemonic_to_dek(&messy_phrase).expect("Recover messy");
assert_eq!(*dek, *recovered_messy);
}
#[test]
fn test_bip39_invalid_words_and_checksum() {
// 1. Nicht im Wörterbuch enthaltenes Wort
let invalid_word_phrase = "abandon amount anchor animal archive arm armed army armor arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow fakeinvalidword";
assert!(mnemonic_to_dek(invalid_word_phrase).is_err());
// 2. Falsche Wortanzahl (z. B. 23 statt 24)
let short_phrase = "abandon amount anchor animal archive arm armed army armor arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow arrow";
assert!(mnemonic_to_dek(short_phrase).is_err());
// 3. Gültige Wörter, aber Prüfsumme ungültig (letztes Wort verändert)
let dek = generate_dek();
let mut words: Vec<String> = dek_to_mnemonic(&dek)
.unwrap()
.split_whitespace()
.map(|s| s.to_string())
.collect();
// Tausche das letzte Wort gegen ein anderes gültiges BIP-39 Wort
let original_last = words[23].clone();
words[23] = if original_last == "abandon" { "zoo".to_string() } else { "abandon".to_string() };
let corrupted_phrase = words.join(" ");
assert!(mnemonic_to_dek(&corrupted_phrase).is_err(), "Checksum check must fail");
}
}
+2
View File
@@ -1,5 +1,7 @@
pub mod crypto;
pub mod mount;
pub mod recovery;
pub mod storage;
pub mod ui;
pub mod verify;
pub mod vfs;
+257 -18
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,6 +219,15 @@ fn handle_passwd(container_path: &Path) -> Result<()> {
println!(" Container: {}", container_path.display());
println!();
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
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")?;
@@ -166,9 +237,6 @@ fn handle_passwd(container_path: &Path) -> Result<()> {
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")?;
@@ -177,13 +245,14 @@ fn handle_passwd(container_path: &Path) -> Result<()> {
let old_kek = derive_kek(&old_password, &meta.kdf_salt, &meta.kdf_params)
.context("Schlüsselableitung fehlgeschlagen")?;
let dek = unwrap_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."))?;
.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 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)?;
}
}
+22 -3
View File
@@ -12,11 +12,18 @@ use tokio::net::TcpListener;
use tokio::sync::watch;
use tracing::{debug, warn};
use crate::crypto::{derive_kek, unwrap_dek};
use crate::crypto::{derive_kek, mnemonic_to_dek, unwrap_dek};
use crate::storage::Database;
use crate::ui;
use crate::vfs::SanctumFs;
/// Authentifizierungsmethode für das Einbinden eines Containers: Entweder Master-Passwort oder 24-Wort Notfallschlüssel.
#[derive(Debug, Clone)]
pub enum ContainerAuth {
Password(String),
RecoveryKey(String),
}
/// Hilfsfunktion zur Formatierung des Laufwerksbuchstabens (z. B. 'S' -> "S:")
pub fn format_drive(drive_letter: char) -> String {
format!("{}:", drive_letter.to_ascii_uppercase())
@@ -73,7 +80,7 @@ pub async fn mount_container(
container_path: &Path,
drive_letter: char,
requested_port: Option<u16>,
password: &str,
auth: ContainerAuth,
) -> Result<()> {
let drive_str = format_drive(drive_letter);
@@ -93,6 +100,8 @@ pub async fn mount_container(
.read_meta()
.context("Konnte Container-Header nicht lesen")?;
let (dek, version) = match auth {
ContainerAuth::Password(ref password) => {
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab...");
let kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params)
.context("Schlüsselableitung fehlgeschlagen")?;
@@ -105,9 +114,19 @@ pub async fn mount_container(
&meta.header_tag,
)
.context("Ungültiges Master-Passwort oder Container beschädigt")?;
(dek, meta.version)
}
ContainerAuth::RecoveryKey(ref phrase) => {
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel...");
let dek = mnemonic_to_dek(phrase)
.context("Ungültiger 24-Wort Notfallschlüssel")?;
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
(dek, meta.version)
}
};
// WebDAV Filesystem und Handler konfigurieren
let fs = SanctumFs::new(db.clone(), dek, meta.version);
let fs = SanctumFs::new(db.clone(), dek, version);
let dav_server = DavHandler::builder()
.filesystem(Box::new(fs))
.locksystem(FakeLs::new())
+301
View File
@@ -0,0 +1,301 @@
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::crypto::{
derive_kek, mnemonic_to_dek, wrap_dek, KdfParams, FORMAT_VERSION,
};
use crate::storage::{ContainerMeta, Database};
pub const HEADER_BACKUP_MAGIC: &str = "SANCTUM_HEADER_BACKUP";
pub const CURRENT_BACKUP_VERSION: u32 = 1;
/// Struktur für exportierte Header-Backups (.sanctum.hdr) im JSON-Format.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeaderBackup {
pub magic: String,
pub backup_version: u32,
pub container_format_version: u32,
pub kdf_salt_hex: String,
pub kdf_params: KdfParams,
pub wrapped_dek_hex: String,
pub header_nonce_hex: String,
pub header_tag_hex: String,
pub created_at: u64,
}
impl HeaderBackup {
pub fn from_meta(meta: &ContainerMeta) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
Self {
magic: HEADER_BACKUP_MAGIC.to_string(),
backup_version: CURRENT_BACKUP_VERSION,
container_format_version: meta.version,
kdf_salt_hex: hex::encode(meta.kdf_salt),
kdf_params: meta.kdf_params.clone(),
wrapped_dek_hex: hex::encode(&meta.wrapped_dek),
header_nonce_hex: hex::encode(meta.header_nonce),
header_tag_hex: hex::encode(meta.header_tag),
created_at: now,
}
}
pub fn to_meta(&self) -> Result<ContainerMeta> {
if self.magic != HEADER_BACKUP_MAGIC {
bail!("Ungültige Header-Backup-Datei: Falsches Magic-Präfix");
}
let salt_bytes = hex::decode(&self.kdf_salt_hex)
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
if salt_bytes.len() != 16 {
bail!("Ungültige Salt-Länge im Backup: erwartet 16 Bytes, erhalten {}", salt_bytes.len());
}
let mut kdf_salt = [0u8; 16];
kdf_salt.copy_from_slice(&salt_bytes);
let wrapped_dek = hex::decode(&self.wrapped_dek_hex)
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
let nonce_bytes = hex::decode(&self.header_nonce_hex)
.context("Ungültige Hex-Kodierung für Header-Nonce")?;
if nonce_bytes.len() != 12 {
bail!("Ungültige Nonce-Länge im Backup: erwartet 12 Bytes, erhalten {}", nonce_bytes.len());
}
let mut header_nonce = [0u8; 12];
header_nonce.copy_from_slice(&nonce_bytes);
let tag_bytes = hex::decode(&self.header_tag_hex)
.context("Ungültige Hex-Kodierung für Header-Tag")?;
if tag_bytes.len() != 16 {
bail!("Ungültige Tag-Länge im Backup: erwartet 16 Bytes, erhalten {}", tag_bytes.len());
}
let mut header_tag = [0u8; 16];
header_tag.copy_from_slice(&tag_bytes);
Ok(ContainerMeta {
version: self.container_format_version,
kdf_salt,
kdf_params: self.kdf_params.clone(),
wrapped_dek,
header_nonce,
header_tag,
})
}
}
/// Sichert die Header-Metadaten eines Containers in eine externe Backup-Datei (.sanctum.hdr).
pub fn export_header_backup(container_path: &Path, backup_path: &Path) -> Result<()> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
}
let db = Database::open(container_path)
.context("Konnte Container-Datenbank zum Lesen des Headers nicht öffnen")?;
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
let backup = HeaderBackup::from_meta(&meta);
let json_data = serde_json::to_string_pretty(&backup)
.context("Fehler beim Serialisieren des Header-Backups")?;
fs::write(backup_path, json_data)
.with_context(|| format!("Konnte Backup-Datei '{}' nicht schreiben", backup_path.display()))?;
Ok(())
}
/// Stellt den Container-Header aus einer Sicherungsdatei (.sanctum.hdr) wieder her.
pub fn restore_header_backup(container_path: &Path, backup_path: &Path) -> Result<()> {
if !backup_path.exists() {
bail!("Backup-Datei '{}' existiert nicht.", backup_path.display());
}
let content = fs::read_to_string(backup_path)
.with_context(|| format!("Konnte Backup-Datei '{}' nicht lesen", backup_path.display()))?;
let backup: HeaderBackup = serde_json::from_str(&content)
.context("Ungültiges Backup-Dateiformat (JSON-Parsing fehlgeschlagen)")?;
let meta = backup.to_meta()?;
let db = Database::open(container_path)
.context("Konnte Ziel-Containerdatei nicht öffnen")?;
db.restore_meta(&meta)
.context("Fehler beim Wiederherstellen der Header-Tabelle in der Datenbank")?;
db.checkpoint().context("Fehler beim WAL-Checkpoint nach Header-Wiederherstellung")?;
Ok(())
}
/// Rekonstruiert den Container-Header vollständig mithilfe des 24-Wort BIP-39 Notfallschlüssels
/// und initialisiert ein neues Master-Passwort.
pub fn restore_header_from_recovery_key(
container_path: &Path,
recovery_key: &str,
new_password: &str,
) -> 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.");
}
// 1. DEK aus 24-Wort-Phrase dekodieren & validieren
let dek = mnemonic_to_dek(recovery_key)
.context("Ungültiger 24-Wort Notfallschlüssel")?;
// 2. Neuen KEK mit frischem Salt ableiten
let mut salt = [0u8; 16];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt);
let kdf_params = KdfParams::default();
let kek = derive_kek(new_password, &salt, &kdf_params)
.context("Schlüsselableitung für neues Passwort fehlgeschlagen")?;
// 3. DEK mit neuem KEK wrappen
let (wrapped_dek, header_nonce, header_tag) =
wrap_dek(&kek, &dek).context("Verschlüsseln des DEK fehlgeschlagen")?;
let meta = ContainerMeta {
version: FORMAT_VERSION,
kdf_salt: salt,
kdf_params,
wrapped_dek,
header_nonce,
header_tag,
};
// 4. In Container schreiben
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
db.restore_meta(&meta)
.context("Fehler beim Schreiben des rekonstruierten Headers")?;
db.checkpoint().context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::{generate_dek, generate_salt, unwrap_dek};
use std::path::PathBuf;
#[test]
fn test_header_backup_and_restore() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_backup_{}.sanctum", std::process::id()));
let backup_path: PathBuf = temp_dir.join(format!("test_backup_{}.sanctum.hdr", std::process::id()));
if container_path.exists() {
let _ = fs::remove_file(&container_path);
}
if backup_path.exists() {
let _ = fs::remove_file(&backup_path);
}
let password = "SuperSecretPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.checkpoint().unwrap();
// 1. Export
export_header_backup(&container_path, &backup_path).expect("Export header");
assert!(backup_path.exists());
// 2. Header in DB gezielt zerstören/löschen
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
drop(conn);
// Prüfen, dass Container jetzt unlesbar ist
let broken_db = Database::open(&container_path).unwrap();
assert!(broken_db.read_meta().is_err());
drop(broken_db);
// 3. Restore
restore_header_backup(&container_path, &backup_path).expect("Restore header");
// 4. Verifikation: Container wieder voll entschlüsselbar
let restored_db = Database::open(&container_path).unwrap();
let meta = restored_db.read_meta().expect("Read restored meta");
let restored_kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params).unwrap();
let active_dek = unwrap_dek(&restored_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag)
.expect("Unwrap restored DEK");
assert_eq!(*dek, *active_dek);
let _ = fs::remove_file(&container_path);
let _ = fs::remove_file(&backup_path);
}
#[test]
fn test_restore_from_recovery_key() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf = temp_dir.join(format!("test_rec_key_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = fs::remove_file(&container_path);
}
let old_password = "ForgottenOldPassword!";
let new_password = "BrandNewRescuedPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(old_password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
// 24-Wort Notfallschlüssel sichern
let phrase = crate::crypto::dek_to_mnemonic(&dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
db.checkpoint().unwrap();
// Header zerstören
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
drop(conn);
// Mit 24-Wort Schlüssel und NEUEM Passwort wiederherstellen
restore_header_from_recovery_key(&container_path, &phrase, new_password)
.expect("Restore from recovery key");
// Verifikation: Neues Passwort entschlüsselt originalen DEK
let rescued_db = Database::open(&container_path).unwrap();
let meta = rescued_db.read_meta().expect("Read rescued meta");
let new_kek = derive_kek(new_password, &meta.kdf_salt, &meta.kdf_params).unwrap();
let unwrapped = unwrap_dek(&new_kek, &meta.wrapped_dek, &meta.header_nonce, &meta.header_tag)
.expect("Unwrap rescued DEK");
assert_eq!(*dek, *unwrapped);
let _ = fs::remove_file(&container_path);
}
}
+124
View File
@@ -507,6 +507,130 @@ impl Database {
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
Ok(())
}
/// Schreibt oder stellt die Metadaten in der `meta`-Tabelle wieder her (z. B. nach Restore oder Header-Neugenerierung).
pub fn restore_meta(&self, meta: &ContainerMeta) -> Result<()> {
let conn = self.conn.lock().unwrap();
let params_json = serde_json::to_string(&meta.kdf_params)?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS meta (
magic BLOB NOT NULL,
version INTEGER NOT NULL,
kdf_salt BLOB NOT NULL,
kdf_params TEXT NOT NULL,
wrapped_dek BLOB NOT NULL,
header_nonce BLOB NOT NULL,
header_tag BLOB NOT NULL
);",
)?;
conn.execute("DELETE FROM meta", [])?;
conn.execute(
"INSERT INTO meta (magic, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
MAGIC_BYTES.as_slice(),
meta.version,
meta.kdf_salt.as_slice(),
params_json,
meta.wrapped_dek,
meta.header_nonce.as_slice(),
meta.header_tag.as_slice(),
],
)?;
Ok(())
}
/// Führt SQLite-eigene Integritäts- und Foreign-Key-Prüfungen aus.
pub fn run_sqlite_integrity_check(&self) -> Result<Vec<String>> {
let conn = self.conn.lock().unwrap();
let mut issues = Vec::new();
// 1. PRAGMA integrity_check
let mut stmt = conn.prepare("PRAGMA integrity_check;")?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
for r in rows {
let msg = r?;
if msg.to_lowercase() != "ok" {
issues.push(format!("SQLite integrity error: {msg}"));
}
}
// 2. PRAGMA foreign_key_check
let mut fk_stmt = conn.prepare("PRAGMA foreign_key_check;")?;
let fk_rows = fk_stmt.query_map([], |row| {
let table: String = row.get(0)?;
let rowid: i64 = row.get(1)?;
let parent: String = row.get(2)?;
let fkid: i64 = row.get(3)?;
Ok(format!(
"Foreign Key Verletzung in Tabelle '{table}', RowId {rowid}, Ziel '{parent}', FK #{fkid}"
))
})?;
for r in fk_rows {
issues.push(r?);
}
Ok(issues)
}
/// Zählt die Anzahl von Verzeichnissen, Dateien und Daten-Chunks im Container.
pub fn count_nodes_and_chunks(&self) -> Result<(usize, usize, usize)> {
let conn = self.conn.lock().unwrap();
let dirs: i64 =
conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 1", [], |r| {
r.get(0)
})?;
let files: i64 =
conn.query_row("SELECT COUNT(*) FROM nodes WHERE is_dir = 0", [], |r| {
r.get(0)
})?;
let chunks: i64 = conn.query_row("SELECT COUNT(*) FROM chunks", [], |r| r.get(0))?;
Ok((dirs as usize, files as usize, chunks as usize))
}
/// Listet alle Knoten (Dateien und Ordner) im gesamten Baum auf.
pub fn list_all_nodes(&self) -> Result<Vec<NodeRecord>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, parent_id, name, is_dir, size, created_at, modified_at FROM nodes ORDER BY id ASC",
)?;
let rows = stmt.query_map([], |row| {
Ok(NodeRecord {
id: row.get(0)?,
parent_id: row.get(1)?,
name: row.get(2)?,
is_dir: row.get::<_, i32>(3)? != 0,
size: row.get::<_, i64>(4)? as u64,
created_at: row.get::<_, i64>(5)? as u64,
modified_at: row.get::<_, i64>(6)? as u64,
})
})?;
let mut result = Vec::new();
for r in rows {
result.push(r?);
}
Ok(result)
}
/// Liefert alle vorhandenen Chunk-Identifikatoren (node_id, chunk_index).
pub fn list_all_chunk_headers(&self) -> Result<Vec<(i64, u32)>> {
let conn = self.conn.lock().unwrap();
let mut stmt =
conn.prepare("SELECT node_id, chunk_index FROM chunks ORDER BY node_id, chunk_index")?;
let rows = stmt.query_map([], |row| {
let nid: i64 = row.get(0)?;
let cidx: u32 = row.get(1)?;
Ok((nid, cidx))
})?;
let mut result = Vec::new();
for r in rows {
result.push(r?);
}
Ok(result)
}
}
#[cfg(test)]
+98
View File
@@ -97,3 +97,101 @@ pub fn step(num: u8, total: u8, icon: &str, msg: &str) {
let tag = cyan(&format!("[{}/{}]", num, total));
println!(" {} {} {}", tag, icon, msg);
}
/// Gibt den 24-Wort BIP-39 Notfall-Wiederherstellungsschlüssel in einer hervorgehobenen Sicherheitsbox aus.
pub fn print_recovery_phrase_card(phrase: &str) {
let words: Vec<&str> = phrase.split_whitespace().collect();
println!();
println!("{}", yellow("┌─────────────────────────────────────────────────────────────┐"));
println!("{}", yellow("│ ⚠️ 24-WORT NOTFALL-WIEDERHERSTELLUNGSSCHLÜSSEL │"));
println!("{}", yellow("├─────────────────────────────────────────────────────────────┤"));
println!("│ Falls Sie Ihr Master-Passwort vergessen oder der Header │");
println!("│ beschädigt wird, ist dies Ihre EINZIGE Rettung! │");
println!("│ Notieren Sie die Wörter in EXAKTER Reihenfolge auf Papier! │");
println!("{}", yellow("├─────────────────────────────────────────────────────────────┤"));
for row in 0..8 {
let w1 = if row < words.len() {
format!("{:2}. {:<11}", row + 1, words[row])
} else {
"".to_string()
};
let w2 = if row + 8 < words.len() {
format!("{:2}. {:<11}", row + 9, words[row + 8])
} else {
"".to_string()
};
let w3 = if row + 16 < words.len() {
format!("{:2}. {:<11}", row + 17, words[row + 16])
} else {
"".to_string()
};
println!("{:<18} {:<18} {:<18}", cyan(&w1), cyan(&w2), cyan(&w3));
}
println!("{}", yellow("└─────────────────────────────────────────────────────────────┘"));
println!();
}
/// Gibt den detaillierten Bericht einer Container-Integritätsprüfung aus.
pub fn print_verification_report(report: &crate::verify::VerificationReport) {
println!();
println!("┌─────────────────────────────────────────────────────────────┐");
println!("│ Sanctum Container-Integritätsprüfung (FSCK) │");
println!("└─────────────────────────────────────────────────────────────┘");
println!(" Container: {}", report.container_path);
println!(" Format: Version {}", report.format_version);
println!();
let sqlite_status = if report.sqlite_ok {
green("✔ OK")
} else {
red("✖ FEHLER")
};
let header_status = if report.header_ok {
green("✔ OK")
} else {
red("✖ BESCHÄDIGT")
};
let tree_status = if report.orphan_nodes == 0 {
green("✔ KONSISTENT")
} else {
red("✖ INKONSISTENT")
};
let chunk_status = if report.corrupted_chunks == 0 {
green("✔ AUTHENTIFIZIERT")
} else {
red("✖ BESCHÄDIGT")
};
println!(" • SQLite B-Tree Integrität: {}", sqlite_status);
println!(" • Header & KDF-Metadaten: {}", header_status);
println!(" • Verzeichnisbaum & Inodes: {}", tree_status);
println!(" • AEAD Chunk-Authentizität: {}", chunk_status);
println!();
println!(" Statistiken:");
println!(" - Ordner: {}", report.total_dirs);
println!(" - Dateien: {}", report.total_files);
println!(" - Daten-Chunks: {}", report.total_chunks);
if report.total_bytes_decrypted > 0 {
let mb = report.total_bytes_decrypted as f64 / (1024.0 * 1024.0);
println!(" - Verifiziert: {:.2} MB (vollständig entschlüsselt)", mb);
}
if !report.errors.is_empty() {
println!();
println!("{}", red(" Gefundene Probleme / Fehler:"));
for err in &report.errors {
println!(" {} {}", red(""), err);
}
}
println!();
if report.is_healthy() {
println!(" {}", green("✔ Keine Beschädigungen oder Bitrot festgestellt. Der Container ist integer."));
} else {
println!(" {}", red("✖ ACHTUNG: Der Container weist Beschädigungen auf! Bitte Backup prüfen."));
}
println!();
}
+343
View File
@@ -0,0 +1,343 @@
use std::collections::{HashMap, HashSet};
use std::path::Path;
use anyhow::{bail, Context, Result};
use zeroize::Zeroizing;
use crate::crypto::{decrypt_chunk, FORMAT_VERSION_V1, FORMAT_VERSION_V2};
use crate::storage::Database;
/// Bericht über das Ergebnis einer Container-Integritätsprüfung.
#[derive(Debug, Clone)]
pub struct VerificationReport {
pub container_path: String,
pub format_version: u32,
pub sqlite_ok: bool,
pub sqlite_errors: Vec<String>,
pub header_ok: bool,
pub header_error: Option<String>,
pub total_nodes: usize,
pub total_dirs: usize,
pub total_files: usize,
pub total_chunks: usize,
pub total_bytes_decrypted: u64,
pub corrupted_chunks: usize,
pub orphan_nodes: usize,
pub errors: Vec<String>,
}
impl VerificationReport {
pub fn is_healthy(&self) -> bool {
self.sqlite_ok
&& self.header_ok
&& self.corrupted_chunks == 0
&& self.orphan_nodes == 0
&& self.errors.is_empty()
}
}
/// Führt eine detaillierte Integritäts- und Bitrot-Prüfung auf einem Sanctum-Container durch.
pub fn verify_container(
container_path: &Path,
dek: Option<&Zeroizing<[u8; 32]>>,
full_chunks: bool,
) -> Result<VerificationReport> {
if !container_path.exists() {
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
}
let db = Database::open(container_path)
.context("Konnte Container-Datenbank nicht öffnen")?;
let mut report = VerificationReport {
container_path: container_path.display().to_string(),
format_version: 0,
sqlite_ok: true,
sqlite_errors: Vec::new(),
header_ok: true,
header_error: None,
total_nodes: 0,
total_dirs: 0,
total_files: 0,
total_chunks: 0,
total_bytes_decrypted: 0,
corrupted_chunks: 0,
orphan_nodes: 0,
errors: Vec::new(),
};
// 1. SQLite B-Tree & Foreign Key Prüfung
let sqlite_issues = db.run_sqlite_integrity_check()
.context("Fehler bei der Ausführung des SQLite integrity_check")?;
if !sqlite_issues.is_empty() {
report.sqlite_ok = false;
report.sqlite_errors = sqlite_issues;
}
// 2. Header & Magic Bytes Prüfung
let meta = match db.read_meta() {
Ok(m) => {
report.format_version = m.version;
if m.version != FORMAT_VERSION_V1 && m.version != FORMAT_VERSION_V2 {
report.header_ok = false;
report.header_error = Some(format!("Unbekannte Formatversion: {}", m.version));
}
if m.kdf_salt.len() != 16 {
report.header_ok = false;
report.header_error = Some("Ungültige KDF-Salt-Länge".to_string());
}
Some(m)
}
Err(e) => {
report.header_ok = false;
report.header_error = Some(format!("{e}"));
None
}
};
// 3. Node-Hierarchie & Strukturprüfung
let (dirs, files, chunks_count) = db.count_nodes_and_chunks()
.context("Fehler beim Zählen der Knoten und Chunks")?;
report.total_dirs = dirs;
report.total_files = files;
report.total_chunks = chunks_count;
let all_nodes = db.list_all_nodes().context("Fehler beim Laden der Knotenliste")?;
report.total_nodes = all_nodes.len();
let mut node_map = HashMap::new();
for node in &all_nodes {
node_map.insert(node.id, node.clone());
}
// Root-Knoten prüfen (id = 1)
match node_map.get(&1) {
Some(root) => {
if !root.is_dir {
report.errors.push("Root-Knoten (id=1) ist nicht als Verzeichnis markiert!".to_string());
}
if root.parent_id.is_some() {
report.errors.push("Root-Knoten (id=1) darf keinen Parent haben!".to_string());
}
}
None => {
report.errors.push("Root-Knoten (id=1) fehlt in der nodes-Tabelle!".to_string());
}
}
// Alle anderen Knoten prüfen: Existenz des Parents, keine Zyklen
for node in &all_nodes {
if node.id == 1 {
continue;
}
match node.parent_id {
Some(pid) => match node_map.get(&pid) {
Some(parent) => {
if !parent.is_dir {
report.errors.push(format!(
"Knoten '{}' (id={}) hat einen Parent (id={}), der kein Verzeichnis ist!",
node.name, node.id, pid
));
}
}
None => {
report.orphan_nodes += 1;
report.errors.push(format!(
"Verwaister Knoten: '{}' (id={}) verweist auf nicht-existenten Parent id={}",
node.name, node.id, pid
));
}
},
None => {
report.orphan_nodes += 1;
report.errors.push(format!(
"Verwaister Knoten ohne Parent: '{}' (id={})",
node.name, node.id
));
}
}
// Zyklenprüfung
let mut visited = HashSet::new();
visited.insert(node.id);
let mut curr_parent = node.parent_id;
while let Some(pid) = curr_parent {
if !visited.insert(pid) {
report.errors.push(format!(
"Zyklische Verzeichnisreferenz bei Knoten '{}' (id={}) entdeckt!",
node.name, node.id
));
break;
}
curr_parent = node_map.get(&pid).and_then(|n| n.parent_id);
}
}
// 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung
let chunk_headers = db.list_all_chunk_headers()
.context("Fehler beim Abrufen der Chunk-Liste")?;
let format_version = meta.as_ref().map(|m| m.version).unwrap_or(FORMAT_VERSION_V2);
for (node_id, chunk_index) in chunk_headers {
if !node_map.contains_key(&node_id) {
report.errors.push(format!(
"Verwaister Daten-Chunk: Node #{node_id} Chunk #{chunk_index} gehört zu keinem bekannten Inode!"
));
}
if let Some(active_dek) = dek {
if full_chunks {
match db.read_chunk(node_id, chunk_index) {
Ok(Some(record)) => {
match decrypt_chunk(
active_dek,
node_id,
chunk_index,
&record.ciphertext,
&record.nonce,
&record.tag,
format_version,
) {
Ok(plaintext) => {
report.total_bytes_decrypted += plaintext.len() as u64;
}
Err(e) => {
report.corrupted_chunks += 1;
report.errors.push(format!(
"AEAD/Integritätsfehler bei Node #{node_id} Chunk #{chunk_index}: {e}"
));
}
}
}
Ok(None) => {
report.errors.push(format!(
"Chunk #{chunk_index} für Node #{node_id} in Index gefunden, aber Daten nicht lesbar!"
));
}
Err(e) => {
report.corrupted_chunks += 1;
report.errors.push(format!(
"DB-Lesefehler bei Node #{node_id} Chunk #{chunk_index}: {e}"
));
}
}
}
}
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::{
derive_kek, encrypt_chunk, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION,
};
use std::fs;
use std::path::PathBuf;
#[test]
fn test_verify_healthy_container() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf =
temp_dir.join(format!("test_verify_ok_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = fs::remove_file(&container_path);
}
let password = "HealthyTestPassword123!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
// Verzeichnis & Datei anlegen
let folder = db.create_node(1, "photos", true).unwrap();
let file = db.create_node(folder.id, "img.jpg", false).unwrap();
// 2 Chunks schreiben
let chunk0_data = b"Sample JPEG data header and pixels";
let (ct0, n0, t0) = encrypt_chunk(&dek, file.id, 0, chunk0_data, FORMAT_VERSION).unwrap();
db.write_chunk(file.id, 0, &n0, &t0, &ct0).unwrap();
let chunk1_data = b"Additional payload data bytes";
let (ct1, n1, t1) = encrypt_chunk(&dek, file.id, 1, chunk1_data, FORMAT_VERSION).unwrap();
db.write_chunk(file.id, 1, &n1, &t1, &ct1).unwrap();
db.update_node_size_and_time(file.id, (chunk0_data.len() + chunk1_data.len()) as u64, 1000).unwrap();
db.checkpoint().unwrap();
// Verifizieren
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
assert!(report.is_healthy(), "Container must be healthy, report: {:?}", report);
assert_eq!(report.total_files, 1);
assert_eq!(report.total_dirs, 2); // Root + photos
assert_eq!(report.total_chunks, 2);
assert_eq!(report.corrupted_chunks, 0);
assert_eq!(report.orphan_nodes, 0);
let _ = fs::remove_file(&container_path);
}
#[test]
fn test_verify_detects_bitrot() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf =
temp_dir.join(format!("test_verify_bitrot_{}.sanctum", std::process::id()));
if container_path.exists() {
let _ = fs::remove_file(&container_path);
}
let password = "BitrotTestPassword123!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(password, &salt, &kdf_params).unwrap();
let dek = generate_dek();
let (wrapped_dek, nonce, tag) = wrap_dek(&kek, &dek).unwrap();
let db = Database::open(&container_path).unwrap();
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
let file = db.create_node(1, "document.pdf", false).unwrap();
let chunk_data = b"Vital documents that must not be corrupted";
let (ct, n, t) = encrypt_chunk(&dek, file.id, 0, chunk_data, FORMAT_VERSION).unwrap();
db.write_chunk(file.id, 0, &n, &t, &ct).unwrap();
db.checkpoint().unwrap();
drop(db);
// Bitrot simulieren: Wir flippen 1 Byte im Ciphertext in SQLite direkt
let conn = rusqlite::Connection::open(&container_path).unwrap();
let mut corrupted_ct = ct.clone();
corrupted_ct[4] ^= 0xFF; // Bit-Flip!
conn.execute(
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0",
rusqlite::params![corrupted_ct, file.id],
).unwrap();
drop(conn);
// Verifizieren: Muss Bitrot via AEAD Tag-Fehler sofort entlarven!
let report = verify_container(&container_path, Some(&dek), true).expect("Verify container");
assert!(!report.is_healthy(), "Container must report unhealthy due to bitrot");
assert_eq!(report.corrupted_chunks, 1, "Must detect exactly 1 corrupted chunk");
assert!(report.errors.iter().any(|e| e.contains("AEAD/Integritätsfehler")));
let _ = fs::remove_file(&container_path);
}
}
+129 -2
View File
@@ -9,10 +9,12 @@ use dav_server::{
use futures_util::StreamExt;
use sanctum::{
crypto::{
derive_kek, encrypt_chunk, generate_dek, generate_salt, unwrap_dek, wrap_dek, KdfParams,
CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
dek_to_mnemonic, derive_kek, encrypt_chunk, generate_dek, generate_salt, unwrap_dek,
wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
},
recovery::{export_header_backup, restore_header_backup, restore_header_from_recovery_key},
storage::Database,
verify::verify_container,
vfs::SanctumFs,
};
@@ -372,3 +374,128 @@ async fn test_sanctum_v1_backward_compatibility() {
let _ = std::fs::remove_file(&container_path);
}
#[tokio::test]
async fn test_sanctum_disaster_recovery_workflow() {
let temp_dir = std::env::temp_dir();
let container_path: PathBuf =
temp_dir.join(format!("test_disaster_{}.sanctum", std::process::id()));
let backup_path: PathBuf =
temp_dir.join(format!("test_disaster_{}.sanctum.hdr", std::process::id()));
if container_path.exists() {
let _ = std::fs::remove_file(&container_path);
}
if backup_path.exists() {
let _ = std::fs::remove_file(&backup_path);
}
let initial_password = "PrimaryPassword2026!";
let salt = generate_salt();
let kdf_params = KdfParams {
memory_cost: 1024,
time_cost: 1,
parallelism: 1,
};
let kek = derive_kek(initial_password, &salt, &kdf_params).expect("KEK");
let dek = generate_dek();
let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).expect("wrap");
// 1. Container erstellen und Datei schreiben
let db = Database::open(&container_path).expect("open db");
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).expect("init schema");
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
let test_file = DavPath::new("/confidential.doc").unwrap();
let secret_payload = b"Top secret corporate documents protected by Sanctum.";
let mut opts_w = OpenOptions::default();
opts_w.write = true;
opts_w.create_new = true;
let mut f = fs.open(&test_file, opts_w).await.expect("create file");
f.write_bytes(Bytes::from_static(secret_payload)).await.expect("write");
f.flush().await.expect("flush");
drop(f);
drop(fs);
db.checkpoint().expect("checkpoint");
// 2. Header-Backup exportieren & 24-Wort Notfallschlüssel sichern
export_header_backup(&container_path, &backup_path).expect("Export header backup");
assert!(backup_path.exists());
let recovery_phrase = dek_to_mnemonic(&dek).expect("Generate 24-word recovery phrase");
assert_eq!(recovery_phrase.split_whitespace().count(), 24);
// 3. Integritätsprüfung vor Katastrophe
let initial_report = verify_container(&container_path, Some(&dek), true).expect("Verify initial");
assert!(initial_report.is_healthy());
assert_eq!(initial_report.total_files, 1);
assert_eq!(initial_report.corrupted_chunks, 0);
// 4. KATASTROPHE 1: Header in SQLite löschen
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
drop(conn);
// Container darf sich ohne Header nicht mehr öffnen lassen
let broken_db = Database::open(&container_path).unwrap();
assert!(broken_db.read_meta().is_err());
drop(broken_db);
// 5. WIEDERHERSTELLUNG 1: Aus externem .sanctum.hdr Backup restoren
restore_header_backup(&container_path, &backup_path).expect("Restore from backup file");
let restored_db = Database::open(&container_path).unwrap();
let restored_meta = restored_db.read_meta().expect("Read restored meta");
let restored_kek = derive_kek(initial_password, &restored_meta.kdf_salt, &restored_meta.kdf_params).unwrap();
let active_dek1 = unwrap_dek(&restored_kek, &restored_meta.wrapped_dek, &restored_meta.header_nonce, &restored_meta.header_tag).unwrap();
let fs1 = SanctumFs::new(restored_db.clone(), active_dek1, restored_meta.version);
let mut opts_r = OpenOptions::default();
opts_r.read = true;
let mut rf1 = fs1.open(&test_file, opts_r).await.expect("open restored");
let read_back1 = rf1.read_bytes(secret_payload.len()).await.expect("read");
assert_eq!(&read_back1[..], secret_payload, "Data must be intact after header restore");
drop(rf1);
drop(fs1);
drop(restored_db);
// 6. KATASTROPHE 2: Header erneut zerstört UND ursprüngliches Passwort vergessen!
let conn = rusqlite::Connection::open(&container_path).unwrap();
conn.execute("DELETE FROM meta", []).unwrap();
drop(conn);
// 7. WIEDERHERSTELLUNG 2: Via 24-Wort Notfallschlüssel mit BRANDNEUEM Passwort
let brand_new_password = "BrandNewRescuedVaultPassphrase2026!";
restore_header_from_recovery_key(&container_path, &recovery_phrase, brand_new_password)
.expect("Restore from 24-word recovery key");
let rescued_db = Database::open(&container_path).unwrap();
let rescued_meta = rescued_db.read_meta().expect("Read rescued meta");
let rescued_kek = derive_kek(brand_new_password, &rescued_meta.kdf_salt, &rescued_meta.kdf_params).unwrap();
let active_dek2 = unwrap_dek(&rescued_kek, &rescued_meta.wrapped_dek, &rescued_meta.header_nonce, &rescued_meta.header_tag).unwrap();
let fs2 = SanctumFs::new(rescued_db.clone(), active_dek2, rescued_meta.version);
let mut opts_r2 = OpenOptions::default();
opts_r2.read = true;
let mut rf2 = fs2.open(&test_file, opts_r2).await.expect("open with new password");
let read_back2 = rf2.read_bytes(secret_payload.len()).await.expect("read");
assert_eq!(&read_back2[..], secret_payload, "Data must be intact after emergency recovery key rescue");
drop(rf2);
drop(fs2);
drop(rescued_db);
// 8. BITROT-ERKENNUNG: Testen, dass verify korrumpierte Chunks detektiert
let conn = rusqlite::Connection::open(&container_path).unwrap();
let mut current_ct: Vec<u8> = conn.query_row("SELECT ciphertext FROM chunks LIMIT 1", [], |r| r.get(0)).unwrap();
current_ct[0] ^= 0x01; // Bit-Flip
conn.execute("UPDATE chunks SET ciphertext = ?1", rusqlite::params![current_ct]).unwrap();
drop(conn);
let bitrot_report = verify_container(&container_path, Some(&dek), true).expect("Verify bitrot");
assert!(!bitrot_report.is_healthy(), "Container must flag bitrot");
assert_eq!(bitrot_report.corrupted_chunks, 1);
// Aufräumen
let _ = std::fs::remove_file(&container_path);
let _ = std::fs::remove_file(&backup_path);
}