feat(recovery): implement header backup/restore, BIP-39 recovery key, and integrity verification
This commit is contained in:
Generated
+68
@@ -136,6 +136,12 @@ dependencies = [
|
|||||||
"password-hash",
|
"password-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -160,6 +166,27 @@ version = "1.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
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]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.1"
|
version = "2.13.1"
|
||||||
@@ -596,6 +623,21 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
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]]
|
[[package]]
|
||||||
name = "htmlescape"
|
name = "htmlescape"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1193,11 +1235,13 @@ dependencies = [
|
|||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
"bip39",
|
||||||
"bytes",
|
"bytes",
|
||||||
"clap",
|
"clap",
|
||||||
"dav-server",
|
"dav-server",
|
||||||
"dyn-clone",
|
"dyn-clone",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"hex",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
@@ -1411,6 +1455,21 @@ dependencies = [
|
|||||||
"zerovec",
|
"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]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.53.1"
|
version = "1.53.1"
|
||||||
@@ -1538,6 +1597,15 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
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]]
|
[[package]]
|
||||||
name = "universal-hash"
|
name = "universal-hash"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ serde = { version = "1.0", features = ["derive"] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
dyn-clone = "1.0"
|
dyn-clone = "1.0"
|
||||||
lz4_flex = "0.11"
|
lz4_flex = "0.11"
|
||||||
|
bip39 = { version = "2.2", features = ["zeroize"] }
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|||||||
@@ -129,6 +129,37 @@ pub fn unwrap_dek(
|
|||||||
Ok(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:
|
/// 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).
|
/// node_id (8 Bytes Little-Endian) || chunk_index (8 Bytes Little-Endian).
|
||||||
#[inline]
|
#[inline]
|
||||||
@@ -342,4 +373,44 @@ mod tests {
|
|||||||
assert_eq!(decrypted, plaintext);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod mount;
|
pub mod mount;
|
||||||
|
pub mod recovery;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
pub mod verify;
|
||||||
pub mod vfs;
|
pub mod vfs;
|
||||||
|
|||||||
+257
-18
@@ -5,11 +5,16 @@ use clap::{Parser, Subcommand};
|
|||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use sanctum::crypto::{
|
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::storage::Database;
|
||||||
use sanctum::ui;
|
use sanctum::ui;
|
||||||
|
use sanctum::verify::verify_container;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "sanctum")]
|
#[command(name = "sanctum")]
|
||||||
@@ -43,6 +48,10 @@ enum Commands {
|
|||||||
/// Optionaler TCP-Port für den lokalen WebDAV-Server (Standard: 8443)
|
/// Optionaler TCP-Port für den lokalen WebDAV-Server (Standard: 8443)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
|
|
||||||
|
/// Optionaler 24-Wort Notfall-Wiederherstellungsschlüssel (umgeht Passwortabfrage)
|
||||||
|
#[arg(long)]
|
||||||
|
recovery_key: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Trennt ein eingebundenes Netzlaufwerk manuell
|
/// Trennt ein eingebundenes Netzlaufwerk manuell
|
||||||
@@ -57,6 +66,54 @@ enum Commands {
|
|||||||
/// Pfad zur .sanctum Containerdatei
|
/// Pfad zur .sanctum Containerdatei
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
path: PathBuf,
|
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()
|
db.checkpoint()
|
||||||
.context("Fehler beim finalen WAL-Checkpoint")?;
|
.context("Fehler beim finalen WAL-Checkpoint")?;
|
||||||
|
|
||||||
|
let recovery_phrase =
|
||||||
|
dek_to_mnemonic(&dek).context("Fehler beim Erzeugen der Notfallphrase")?;
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
println!("│ ✔ Sanctum-Container erfolgreich initialisiert! │");
|
println!("│ ✔ Sanctum-Container erfolgreich initialisiert! │");
|
||||||
println!("└─────────────────────────────────────────────────────────────┘");
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
println!();
|
println!();
|
||||||
println!(" • Container: {}", container_path.display());
|
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!(" • 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!();
|
||||||
println!(" Befehl zum Einbinden als Netzlaufwerk:");
|
println!(" Befehl zum Einbinden als Netzlaufwerk:");
|
||||||
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\" --drive S", container_path.display())));
|
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\" --drive S", container_path.display())));
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
|
ui::print_recovery_phrase_card(&recovery_phrase);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_passwd(container_path: &Path) -> Result<()> {
|
fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()> {
|
||||||
if !container_path.exists() {
|
if !container_path.exists() {
|
||||||
bail!(
|
bail!(
|
||||||
"Containerdatei '{}' existiert nicht.",
|
"Containerdatei '{}' existiert nicht.",
|
||||||
@@ -157,6 +219,15 @@ fn handle_passwd(container_path: &Path) -> Result<()> {
|
|||||||
println!(" Container: {}", container_path.display());
|
println!(" Container: {}", container_path.display());
|
||||||
println!();
|
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: ")
|
let old_password = rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
|
||||||
.context("Fehler beim Einlesen des aktuellen Passworts")?;
|
.context("Fehler beim Einlesen des aktuellen Passworts")?;
|
||||||
|
|
||||||
@@ -166,9 +237,6 @@ fn handle_passwd(container_path: &Path) -> Result<()> {
|
|||||||
|
|
||||||
println!();
|
println!();
|
||||||
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
|
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
|
||||||
let db = Database::open(container_path)
|
|
||||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
|
||||||
|
|
||||||
let meta = db
|
let meta = db
|
||||||
.read_meta()
|
.read_meta()
|
||||||
.context("Konnte Container-Header nicht lesen")?;
|
.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)
|
let old_kek = derive_kek(&old_password, &meta.kdf_salt, &meta.kdf_params)
|
||||||
.context("Schlüsselableitung fehlgeschlagen")?;
|
.context("Schlüsselableitung fehlgeschlagen")?;
|
||||||
|
|
||||||
let dek = unwrap_dek(
|
unwrap_dek(
|
||||||
&old_kek,
|
&old_kek,
|
||||||
&meta.wrapped_dek,
|
&meta.wrapped_dek,
|
||||||
&meta.header_nonce,
|
&meta.header_nonce,
|
||||||
&meta.header_tag,
|
&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!();
|
println!();
|
||||||
let new_password = rpassword::prompt_password("Neues Master-Passwort eingeben: ")
|
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!");
|
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!();
|
println!();
|
||||||
ui::step(3, 4, "🔒", "Generiere frisches Salt & leite neuen KEK ab...");
|
ui::step(3, 4, "🔒", "Generiere frisches Salt & leite neuen KEK ab...");
|
||||||
let new_salt = generate_salt();
|
let new_salt = generate_salt();
|
||||||
@@ -240,6 +305,154 @@ fn handle_passwd(container_path: &Path) -> Result<()> {
|
|||||||
Ok(())
|
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<()> {
|
async fn run() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
@@ -247,16 +460,26 @@ async fn run() -> Result<()> {
|
|||||||
Commands::Init { path } => {
|
Commands::Init { path } => {
|
||||||
handle_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 drive_char = parse_drive_letter(&drive)?;
|
||||||
|
let auth = if let Some(key) = recovery_key {
|
||||||
|
ContainerAuth::RecoveryKey(key)
|
||||||
|
} else {
|
||||||
let prompt_text = format!(
|
let prompt_text = format!(
|
||||||
"Master-Passwort für Container '{}' eingeben: ",
|
"Master-Passwort für Container '{}' eingeben: ",
|
||||||
path.display()
|
path.display()
|
||||||
);
|
);
|
||||||
let password = rpassword::prompt_password(prompt_text)
|
let password = rpassword::prompt_password(prompt_text)
|
||||||
.context("Fehler beim Einlesen des Passworts")?;
|
.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 } => {
|
Commands::Unmount { drive } => {
|
||||||
let drive_char = parse_drive_letter(&drive)?;
|
let drive_char = parse_drive_letter(&drive)?;
|
||||||
@@ -267,8 +490,24 @@ async fn run() -> Result<()> {
|
|||||||
println!("{}", ui::green("OK"));
|
println!("{}", ui::green("OK"));
|
||||||
println!("{} Laufwerk {} erfolgreich getrennt.", ui::green("✔"), drive_str);
|
println!("{} Laufwerk {} erfolgreich getrennt.", ui::green("✔"), drive_str);
|
||||||
}
|
}
|
||||||
Commands::Passwd { path } => {
|
Commands::Passwd { path, recovery_key } => {
|
||||||
handle_passwd(&path)?;
|
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
@@ -12,11 +12,18 @@ use tokio::net::TcpListener;
|
|||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
use tracing::{debug, warn};
|
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::storage::Database;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
use crate::vfs::SanctumFs;
|
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:")
|
/// Hilfsfunktion zur Formatierung des Laufwerksbuchstabens (z. B. 'S' -> "S:")
|
||||||
pub fn format_drive(drive_letter: char) -> String {
|
pub fn format_drive(drive_letter: char) -> String {
|
||||||
format!("{}:", drive_letter.to_ascii_uppercase())
|
format!("{}:", drive_letter.to_ascii_uppercase())
|
||||||
@@ -73,7 +80,7 @@ pub async fn mount_container(
|
|||||||
container_path: &Path,
|
container_path: &Path,
|
||||||
drive_letter: char,
|
drive_letter: char,
|
||||||
requested_port: Option<u16>,
|
requested_port: Option<u16>,
|
||||||
password: &str,
|
auth: ContainerAuth,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let drive_str = format_drive(drive_letter);
|
let drive_str = format_drive(drive_letter);
|
||||||
|
|
||||||
@@ -93,6 +100,8 @@ pub async fn mount_container(
|
|||||||
.read_meta()
|
.read_meta()
|
||||||
.context("Konnte Container-Header nicht lesen")?;
|
.context("Konnte Container-Header nicht lesen")?;
|
||||||
|
|
||||||
|
let (dek, version) = match auth {
|
||||||
|
ContainerAuth::Password(ref password) => {
|
||||||
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab...");
|
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab...");
|
||||||
let kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params)
|
let kek = derive_kek(password, &meta.kdf_salt, &meta.kdf_params)
|
||||||
.context("Schlüsselableitung fehlgeschlagen")?;
|
.context("Schlüsselableitung fehlgeschlagen")?;
|
||||||
@@ -105,9 +114,19 @@ pub async fn mount_container(
|
|||||||
&meta.header_tag,
|
&meta.header_tag,
|
||||||
)
|
)
|
||||||
.context("Ungültiges Master-Passwort oder Container beschädigt")?;
|
.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
|
// 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()
|
let dav_server = DavHandler::builder()
|
||||||
.filesystem(Box::new(fs))
|
.filesystem(Box::new(fs))
|
||||||
.locksystem(FakeLs::new())
|
.locksystem(FakeLs::new())
|
||||||
|
|||||||
+301
@@ -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
@@ -507,6 +507,130 @@ impl Database {
|
|||||||
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
|
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
|
||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -97,3 +97,101 @@ pub fn step(num: u8, total: u8, icon: &str, msg: &str) {
|
|||||||
let tag = cyan(&format!("[{}/{}]", num, total));
|
let tag = cyan(&format!("[{}/{}]", num, total));
|
||||||
println!(" {} {} {}", tag, icon, msg);
|
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
@@ -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
@@ -9,10 +9,12 @@ use dav_server::{
|
|||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use sanctum::{
|
use sanctum::{
|
||||||
crypto::{
|
crypto::{
|
||||||
derive_kek, encrypt_chunk, generate_dek, generate_salt, unwrap_dek, wrap_dek, KdfParams,
|
dek_to_mnemonic, derive_kek, encrypt_chunk, generate_dek, generate_salt, unwrap_dek,
|
||||||
CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
|
wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
|
||||||
},
|
},
|
||||||
|
recovery::{export_header_backup, restore_header_backup, restore_header_from_recovery_key},
|
||||||
storage::Database,
|
storage::Database,
|
||||||
|
verify::verify_container,
|
||||||
vfs::SanctumFs,
|
vfs::SanctumFs,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -372,3 +374,128 @@ async fn test_sanctum_v1_backward_compatibility() {
|
|||||||
let _ = std::fs::remove_file(&container_path);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user