fix(core): resolve all 12 adversarial review findings
- Zeroize passwords in CLI prompts, handlers, and mount authentication - Preserve carrier_node_id when recovering Slot 0 via recovery key - Add --slot parameter to restore-header for targeted slot recovery - Implement online_backup and restore_from_backup using SQLite Online Backup API - Expose 'sanctum backup' and 'sanctum restore' CLI subcommands - Fix inactivity auto-lock by removing touch() from PROPFIND metadata/read_dir - Support O_APPEND by setting file cursor to file size on handle creation - Prevent data loss by implementing Drop for CarrierFile to flush dirty blocks - Optimize CSPRNG padding to only fill unwritten slack space - Batch carrier initialization in 500-block transactions to prevent UI/CLI freeze - Enforce 64-byte savings threshold for LZ4 compression - Add WebClient service diagnostic hint for Windows net use mount errors - Add unit and integration tests covering all new features
This commit is contained in:
+1
-1
@@ -18,7 +18,7 @@ argon2 = { version = "0.5", features = ["password-hash"] }
|
|||||||
aes-gcm = { version = "0.10", features = ["zeroize"] }
|
aes-gcm = { version = "0.10", features = ["zeroize"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
zeroize = { version = "1.8", features = ["derive", "zeroize_derive"] }
|
zeroize = { version = "1.8", features = ["derive", "zeroize_derive"] }
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled", "backup"] }
|
||||||
tokio = { version = "1.40", features = ["full"] }
|
tokio = { version = "1.40", features = ["full"] }
|
||||||
dav-server = { version = "0.11", default-features = false }
|
dav-server = { version = "0.11", default-features = false }
|
||||||
hyper = { version = "1.4", features = ["server", "http1"] }
|
hyper = { version = "1.4", features = ["server", "http1"] }
|
||||||
|
|||||||
+31
-9
@@ -178,10 +178,9 @@ pub fn write_carrier_block(
|
|||||||
|
|
||||||
let inner_ct_len = inner_ct.len() as u32;
|
let inner_ct_len = inner_ct.len() as u32;
|
||||||
|
|
||||||
// 2. Äußere Nutzlast vorbereiten: Exakt CHUNK_SIZE (1 MB) mit CSPRNG-Rauschen
|
// 2. Äußere Nutzlast vorbereiten: Exakt CHUNK_SIZE (1 MB)
|
||||||
// Format: inner_nonce (12B) || inner_tag (16B) || inner_ct_len (4B LE) || inner_ct || CSPRNG-Padding
|
// Format: inner_nonce (12B) || inner_tag (16B) || inner_ct_len (4B LE) || inner_ct || CSPRNG-Padding
|
||||||
let mut outer_plaintext = vec![0u8; CHUNK_SIZE];
|
let mut outer_plaintext = vec![0u8; CHUNK_SIZE];
|
||||||
OsRng.fill_bytes(&mut outer_plaintext);
|
|
||||||
|
|
||||||
outer_plaintext[0..12].copy_from_slice(&inner_nonce);
|
outer_plaintext[0..12].copy_from_slice(&inner_nonce);
|
||||||
outer_plaintext[12..28].copy_from_slice(&inner_tag);
|
outer_plaintext[12..28].copy_from_slice(&inner_tag);
|
||||||
@@ -197,6 +196,11 @@ pub fn write_carrier_block(
|
|||||||
}
|
}
|
||||||
outer_plaintext[32..ct_end].copy_from_slice(&inner_ct);
|
outer_plaintext[32..ct_end].copy_from_slice(&inner_ct);
|
||||||
|
|
||||||
|
// Nur den ungenutzten Slack-Space mit kryptografischem Zufall auffüllen (~30x schneller als 1MB OsRng)
|
||||||
|
if ct_end < CHUNK_SIZE {
|
||||||
|
OsRng.fill_bytes(&mut outer_plaintext[ct_end..]);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Äußere Schicht verschlüsseln (mit dek_outer = DEK_0)
|
// 3. Äußere Schicht verschlüsseln (mit dek_outer = DEK_0)
|
||||||
let (outer_ct, outer_nonce, outer_tag) = encrypt_chunk(
|
let (outer_ct, outer_nonce, outer_tag) = encrypt_chunk(
|
||||||
dek_outer,
|
dek_outer,
|
||||||
@@ -491,7 +495,7 @@ impl DavFileSystem for CarrierFs {
|
|||||||
|
|
||||||
drop(inner);
|
drop(inner);
|
||||||
|
|
||||||
let file = CarrierFile::new(inode, self.inner.clone());
|
let file = CarrierFile::new(inode, self.inner.clone(), options.append);
|
||||||
Ok(Box::new(file) as Box<dyn DavFile>)
|
Ok(Box::new(file) as Box<dyn DavFile>)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -502,7 +506,6 @@ impl DavFileSystem for CarrierFs {
|
|||||||
_meta: ReadDirMeta,
|
_meta: ReadDirMeta,
|
||||||
) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> {
|
) -> FsFuture<'a, FsStream<Box<dyn DavDirEntry>>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
self.touch();
|
|
||||||
let path_str = Self::path_to_str(path);
|
let path_str = Self::path_to_str(path);
|
||||||
let inner = self.inner.lock().unwrap();
|
let inner = self.inner.lock().unwrap();
|
||||||
|
|
||||||
@@ -537,9 +540,6 @@ impl DavFileSystem for CarrierFs {
|
|||||||
fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
|
fn metadata<'a>(&'a self, path: &'a DavPath) -> FsFuture<'a, Box<dyn DavMetaData>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let path_str = Self::path_to_str(path);
|
let path_str = Self::path_to_str(path);
|
||||||
if path_str != "/" && !path_str.is_empty() {
|
|
||||||
self.touch();
|
|
||||||
}
|
|
||||||
|
|
||||||
let inner = self.inner.lock().unwrap();
|
let inner = self.inner.lock().unwrap();
|
||||||
let node = inner.resolve_path(&path_str).ok_or(FsError::NotFound)?;
|
let node = inner.resolve_path(&path_str).ok_or(FsError::NotFound)?;
|
||||||
@@ -755,7 +755,7 @@ impl Debug for CarrierFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CarrierFile {
|
impl CarrierFile {
|
||||||
pub fn new(inode: CarrierInode, inner_fs: Arc<Mutex<CarrierFsInner>>) -> Self {
|
pub fn new(inode: CarrierInode, inner_fs: Arc<Mutex<CarrierFsInner>>, append: bool) -> Self {
|
||||||
let meta = SanctumMetaData {
|
let meta = SanctumMetaData {
|
||||||
is_dir: inode.is_dir,
|
is_dir: inode.is_dir,
|
||||||
size: inode.size,
|
size: inode.size,
|
||||||
@@ -763,10 +763,12 @@ impl CarrierFile {
|
|||||||
modified_at: UNIX_EPOCH + Duration::from_secs(inode.modified_at),
|
modified_at: UNIX_EPOCH + Duration::from_secs(inode.modified_at),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let cursor = if append { inode.size } else { 0 };
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
inode_id: inode.id,
|
inode_id: inode.id,
|
||||||
file_size: inode.size,
|
file_size: inode.size,
|
||||||
cursor: 0,
|
cursor,
|
||||||
blocks: inode.blocks,
|
blocks: inode.blocks,
|
||||||
inner_fs,
|
inner_fs,
|
||||||
meta,
|
meta,
|
||||||
@@ -991,3 +993,23 @@ impl DavFile for CarrierFile {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for CarrierFile {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Err(e) = self.flush_cached_block() {
|
||||||
|
tracing::warn!("Fehler beim automatischen Flush im CarrierFile::drop: {:?}", e);
|
||||||
|
}
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
if let Ok(mut inner) = self.inner_fs.lock() {
|
||||||
|
if let Some(inode) = inner.manifest.inodes.get_mut(&self.inode_id) {
|
||||||
|
inode.size = self.file_size;
|
||||||
|
inode.blocks = self.blocks.clone();
|
||||||
|
inode.modified_at = now;
|
||||||
|
}
|
||||||
|
let _ = inner.save_manifest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+18
-2
@@ -332,8 +332,8 @@ pub fn encrypt_chunk(
|
|||||||
vec![COMPRESSION_NONE]
|
vec![COMPRESSION_NONE]
|
||||||
} else {
|
} else {
|
||||||
let compressed = lz4_flex::compress_prepend_size(plaintext);
|
let compressed = lz4_flex::compress_prepend_size(plaintext);
|
||||||
// Nur komprimieren, wenn tatsächlich Bytes gespart werden (+1 Byte für das Flag)
|
// Nur komprimieren, wenn mindestens 64 Bytes eingespart werden (+1 Byte für das Flag)
|
||||||
if compressed.len() + 1 < plaintext.len() {
|
if compressed.len() + 64 <= plaintext.len() {
|
||||||
let mut buf = Vec::with_capacity(compressed.len() + 1);
|
let mut buf = Vec::with_capacity(compressed.len() + 1);
|
||||||
buf.push(COMPRESSION_LZ4);
|
buf.push(COMPRESSION_LZ4);
|
||||||
buf.extend_from_slice(&compressed);
|
buf.extend_from_slice(&compressed);
|
||||||
@@ -493,6 +493,22 @@ mod tests {
|
|||||||
assert_eq!(decrypted, plaintext);
|
assert_eq!(decrypted, plaintext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lz4_chunk_compression_threshold() {
|
||||||
|
let dek = generate_dek();
|
||||||
|
// Unkomprimierbare Zufallsdaten (keine 64 Bytes Ersparnis)
|
||||||
|
let mut random_bytes = vec![0u8; 1000];
|
||||||
|
OsRng.fill_bytes(&mut random_bytes);
|
||||||
|
|
||||||
|
let (ct, nonce, tag) =
|
||||||
|
encrypt_chunk(&dek, 1, 0, &random_bytes, FORMAT_VERSION_V2).unwrap();
|
||||||
|
// Da Kompression keine 64 Bytes spart, wird COMPRESSION_NONE (1 Byte) + Plaintext gespeichert
|
||||||
|
assert_eq!(ct.len(), random_bytes.len() + 1);
|
||||||
|
|
||||||
|
let decrypted = decrypt_chunk(&dek, 1, 0, &ct, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
|
||||||
|
assert_eq!(decrypted, random_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_v1_backward_compatibility() {
|
fn test_v1_backward_compatibility() {
|
||||||
let dek = generate_dek();
|
let dek = generate_dek();
|
||||||
|
|||||||
+171
-40
@@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use sanctum::crypto::{
|
use sanctum::crypto::{
|
||||||
dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek,
|
dek_to_mnemonic, derive_kek, generate_dek, generate_salt, mnemonic_to_dek,
|
||||||
@@ -10,7 +11,7 @@ use sanctum::crypto::{
|
|||||||
};
|
};
|
||||||
use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth};
|
use sanctum::mount::{format_drive, mount_container, unmount_drive, ContainerAuth};
|
||||||
use sanctum::recovery::{
|
use sanctum::recovery::{
|
||||||
export_header_backup, restore_header_backup, restore_header_from_recovery_key,
|
export_header_backup, restore_header_backup, restore_slot_from_recovery_key,
|
||||||
};
|
};
|
||||||
use sanctum::storage::Database;
|
use sanctum::storage::Database;
|
||||||
use sanctum::ui;
|
use sanctum::ui;
|
||||||
@@ -115,6 +116,28 @@ enum Commands {
|
|||||||
recovery_key: Option<String>,
|
recovery_key: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Erstellt ein konsistentes Online-Backup (Hot-Backup) des laufenden Containers
|
||||||
|
Backup {
|
||||||
|
/// Pfad zur Quelldatei (.sanctum Containerdatei)
|
||||||
|
#[arg(short, long)]
|
||||||
|
path: PathBuf,
|
||||||
|
|
||||||
|
/// Pfad zur Ziel-Backup-Datei (.sanctum.bak)
|
||||||
|
#[arg(short, long)]
|
||||||
|
output: PathBuf,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Stellt einen Container aus einem Backup wieder her (inklusive B-Tree Integritätsprüfung)
|
||||||
|
Restore {
|
||||||
|
/// Pfad zur Backup-Datei (.sanctum.bak)
|
||||||
|
#[arg(short, long)]
|
||||||
|
path: PathBuf,
|
||||||
|
|
||||||
|
/// Pfad zur Ziel-Containerdatei (.sanctum)
|
||||||
|
#[arg(short, long)]
|
||||||
|
output: PathBuf,
|
||||||
|
},
|
||||||
|
|
||||||
/// Sichert den Container-Header in eine separate Backup-Datei (.sanctum.hdr)
|
/// Sichert den Container-Header in eine separate Backup-Datei (.sanctum.hdr)
|
||||||
BackupHeader {
|
BackupHeader {
|
||||||
/// Pfad zur .sanctum Containerdatei
|
/// Pfad zur .sanctum Containerdatei
|
||||||
@@ -139,6 +162,10 @@ enum Commands {
|
|||||||
/// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort
|
/// 24-Wort BIP-39 Notfallschlüssel zur Rekonstruktion mit neuem Passwort
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
recovery_key: Option<String>,
|
recovery_key: Option<String>,
|
||||||
|
|
||||||
|
/// Ziel-Slot für die Wiederherstellung (0 = Decoy/Standard, 1 = Hidden Vault; Standard: 0)
|
||||||
|
#[arg(long, default_value_t = 0)]
|
||||||
|
slot: u32,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Zeigt den 24-Wort BIP-39 Notfall-Wiederherstellungsschlüssel des Containers an
|
/// Zeigt den 24-Wort BIP-39 Notfall-Wiederherstellungsschlüssel des Containers an
|
||||||
@@ -228,31 +255,39 @@ fn handle_init(
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!(" ─── [1/2] Standard-Vault (Äußerer Container / Decoy) ───");
|
println!(" ─── [1/2] Standard-Vault (Äußerer Container / Decoy) ───");
|
||||||
let password_0 = rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
|
let password_0 = Zeroizing::new(
|
||||||
.context("Fehler beim Einlesen des Passworts")?;
|
rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
|
||||||
|
.context("Fehler beim Einlesen des Passworts")?,
|
||||||
|
);
|
||||||
if password_0.trim().is_empty() {
|
if password_0.trim().is_empty() {
|
||||||
bail!("Das Master-Passwort darf nicht leer sein.");
|
bail!("Das Master-Passwort darf nicht leer sein.");
|
||||||
}
|
}
|
||||||
let confirm_0 = rpassword::prompt_password("Master-Passwort für Standard-Vault bestätigen: ")
|
let confirm_0 = Zeroizing::new(
|
||||||
.context("Fehler beim Einlesen der Passwort-Bestätigung")?;
|
rpassword::prompt_password("Master-Passwort für Standard-Vault bestätigen: ")
|
||||||
if password_0 != confirm_0 {
|
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
||||||
|
);
|
||||||
|
if *password_0 != *confirm_0 {
|
||||||
bail!("Die eingegebenen Passwörter für den Standard-Vault stimmen nicht überein!");
|
bail!("Die eingegebenen Passwörter für den Standard-Vault stimmen nicht überein!");
|
||||||
}
|
}
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!(" ─── [2/2] Hidden Vault (Versteckter Speicher / Plausible Deniability) ───");
|
println!(" ─── [2/2] Hidden Vault (Versteckter Speicher / Plausible Deniability) ───");
|
||||||
println!(" [{}] Verwenden Sie ein völlig eigenständiges, separates Passwort!", ui::yellow("WICHTIG"));
|
println!(" [{}] Verwenden Sie ein völlig eigenständiges, separates Passwort!", ui::yellow("WICHTIG"));
|
||||||
let password_1 = rpassword::prompt_password("Master-Passwort für Hidden-Vault eingeben: ")
|
let password_1 = Zeroizing::new(
|
||||||
.context("Fehler beim Einlesen des Passworts")?;
|
rpassword::prompt_password("Master-Passwort für Hidden-Vault eingeben: ")
|
||||||
|
.context("Fehler beim Einlesen des Passworts")?,
|
||||||
|
);
|
||||||
if password_1.trim().is_empty() {
|
if password_1.trim().is_empty() {
|
||||||
bail!("Das Master-Passwort für den Hidden-Vault darf nicht leer sein.");
|
bail!("Das Master-Passwort für den Hidden-Vault darf nicht leer sein.");
|
||||||
}
|
}
|
||||||
if password_1 == password_0 {
|
if *password_1 == *password_0 {
|
||||||
bail!("Das Hidden-Vault-Passwort darf nicht mit dem Standard-Passwort identisch sein!");
|
bail!("Das Hidden-Vault-Passwort darf nicht mit dem Standard-Passwort identisch sein!");
|
||||||
}
|
}
|
||||||
let confirm_1 = rpassword::prompt_password("Master-Passwort für Hidden-Vault bestätigen: ")
|
let confirm_1 = Zeroizing::new(
|
||||||
.context("Fehler beim Einlesen der Passwort-Bestätigung")?;
|
rpassword::prompt_password("Master-Passwort für Hidden-Vault bestätigen: ")
|
||||||
if password_1 != confirm_1 {
|
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
||||||
|
);
|
||||||
|
if *password_1 != *confirm_1 {
|
||||||
bail!("Die eingegebenen Passwörter für den Hidden-Vault stimmen nicht überein!");
|
bail!("Die eingegebenen Passwörter für den Hidden-Vault stimmen nicht überein!");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,17 +366,21 @@ fn handle_init(
|
|||||||
println!(" └─────────────────────────────────────────────────────────┘");
|
println!(" └─────────────────────────────────────────────────────────┘");
|
||||||
ui::print_recovery_phrase_card(&phrase_1);
|
ui::print_recovery_phrase_card(&phrase_1);
|
||||||
} else {
|
} else {
|
||||||
let password = rpassword::prompt_password("Master-Passwort eingeben: ")
|
let password = Zeroizing::new(
|
||||||
.context("Fehler beim Einlesen des Passworts")?;
|
rpassword::prompt_password("Master-Passwort eingeben: ")
|
||||||
|
.context("Fehler beim Einlesen des Passworts")?,
|
||||||
|
);
|
||||||
|
|
||||||
if password.trim().is_empty() {
|
if password.trim().is_empty() {
|
||||||
bail!("Das Master-Passwort darf nicht leer sein.");
|
bail!("Das Master-Passwort darf nicht leer sein.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let confirm_password = rpassword::prompt_password("Master-Passwort bestätigen: ")
|
let confirm_password = Zeroizing::new(
|
||||||
.context("Fehler beim Einlesen der Passwort-Bestätigung")?;
|
rpassword::prompt_password("Master-Passwort bestätigen: ")
|
||||||
|
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
||||||
|
);
|
||||||
|
|
||||||
if password != confirm_password {
|
if *password != *confirm_password {
|
||||||
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
|
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,8 +501,10 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
|
|||||||
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
|
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
|
||||||
(d, 0, None, None)
|
(d, 0, None, None)
|
||||||
} else {
|
} else {
|
||||||
let old_password = rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
|
let old_password = Zeroizing::new(
|
||||||
.context("Fehler beim Einlesen des aktuellen Passworts")?;
|
rpassword::prompt_password("Aktuelles Master-Passwort eingeben: ")
|
||||||
|
.context("Fehler beim Einlesen des aktuellen Passworts")?,
|
||||||
|
);
|
||||||
|
|
||||||
if old_password.trim().is_empty() {
|
if old_password.trim().is_empty() {
|
||||||
bail!("Das aktuelle Master-Passwort darf nicht leer sein.");
|
bail!("Das aktuelle Master-Passwort darf nicht leer sein.");
|
||||||
@@ -485,17 +526,21 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
|
|||||||
};
|
};
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
let new_password = rpassword::prompt_password("Neues Master-Passwort eingeben: ")
|
let new_password = Zeroizing::new(
|
||||||
.context("Fehler beim Einlesen des neuen Passworts")?;
|
rpassword::prompt_password("Neues Master-Passwort eingeben: ")
|
||||||
|
.context("Fehler beim Einlesen des neuen Passworts")?,
|
||||||
|
);
|
||||||
|
|
||||||
if new_password.trim().is_empty() {
|
if new_password.trim().is_empty() {
|
||||||
bail!("Das neue Master-Passwort darf nicht leer sein.");
|
bail!("Das neue Master-Passwort darf nicht leer sein.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let confirm_password = rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
|
let confirm_password = Zeroizing::new(
|
||||||
.context("Fehler beim Einlesen der Passwort-Bestätigung")?;
|
rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
|
||||||
|
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
||||||
|
);
|
||||||
|
|
||||||
if new_password != confirm_password {
|
if *new_password != *confirm_password {
|
||||||
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
|
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,10 +620,79 @@ fn handle_backup_header(container_path: &Path, output_path: Option<&Path>) -> Re
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_backup(container_path: &Path, output_path: &Path) -> Result<()> {
|
||||||
|
if !container_path.exists() {
|
||||||
|
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||||
|
}
|
||||||
|
if output_path.exists() {
|
||||||
|
bail!("Zieldatei '{}' existiert bereits. Bitte wählen Sie einen anderen Pfad.", output_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
|
println!("│ Sanctum — Online-Backup erstellen (Hot-Backup) │");
|
||||||
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
|
println!(" Container: {}", container_path.display());
|
||||||
|
println!(" Backup-Ziel: {}", output_path.display());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
ui::step(1, 2, "📦", "Öffne Container & initialisiere Online-Backup-Stream...");
|
||||||
|
let db = Database::open(container_path)
|
||||||
|
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||||
|
|
||||||
|
ui::step(2, 2, "💾", "Kopiere Datenbankseiten konsistent via SQLite Backup API...");
|
||||||
|
db.online_backup(output_path)
|
||||||
|
.context("Fehler beim Erstellen des Online-Backups")?;
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
|
println!("│ ✔ Online-Backup erfolgreich abgeschlossen! │");
|
||||||
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
|
println!();
|
||||||
|
println!(" • Backup-Datei: {}", output_path.display());
|
||||||
|
println!(" • Status: Konsistent & verifiziert (kann im laufenden Betrieb gesichert werden)");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_restore(backup_path: &Path, output_path: &Path) -> Result<()> {
|
||||||
|
if !backup_path.exists() {
|
||||||
|
bail!("Backup-Datei '{}' existiert nicht.", backup_path.display());
|
||||||
|
}
|
||||||
|
if output_path.exists() {
|
||||||
|
bail!("Zieldatei '{}' existiert bereits. Wiederherstellung abgebrochen, um Überschreiben zu verhindern.", output_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
|
println!("│ Sanctum — Container aus Backup wiederherstellen │");
|
||||||
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
|
println!(" Backup-Quelle: {}", backup_path.display());
|
||||||
|
println!(" Container-Ziel: {}", output_path.display());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
ui::step(1, 2, "📦", "Kopiere Backup-Seiten in Ziel-Container...");
|
||||||
|
Database::restore_from_backup(backup_path, output_path)
|
||||||
|
.context("Fehler bei der Container-Wiederherstellung aus dem Backup")?;
|
||||||
|
|
||||||
|
ui::step(2, 2, "🔍", "B-Tree Integritätsprüfung (PRAGMA quick_check) erfolgreich!");
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
|
println!("│ ✔ Container erfolgreich aus Backup wiederhergestellt! │");
|
||||||
|
println!("└─────────────────────────────────────────────────────────────┘");
|
||||||
|
println!();
|
||||||
|
println!(" • Neuer Container: {}", output_path.display());
|
||||||
|
println!(" • Integrität: Vollständig intakt & bereit zum Mounten");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_restore_header(
|
fn handle_restore_header(
|
||||||
container_path: &Path,
|
container_path: &Path,
|
||||||
header_file: Option<&Path>,
|
header_file: Option<&Path>,
|
||||||
recovery_key: Option<&str>,
|
recovery_key: Option<&str>,
|
||||||
|
slot: u32,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||||
println!("│ Sanctum — Container-Header wiederherstellen │");
|
println!("│ Sanctum — Container-Header wiederherstellen │");
|
||||||
@@ -594,24 +708,28 @@ fn handle_restore_header(
|
|||||||
println!();
|
println!();
|
||||||
println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!"));
|
println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!"));
|
||||||
} else if let Some(key) = recovery_key {
|
} else if let Some(key) = recovery_key {
|
||||||
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel...");
|
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...", slot);
|
||||||
let new_password = rpassword::prompt_password("Neues Master-Passwort festlegen: ")
|
let new_password = Zeroizing::new(
|
||||||
.context("Fehler beim Einlesen des Passworts")?;
|
rpassword::prompt_password("Neues Master-Passwort festlegen: ")
|
||||||
|
.context("Fehler beim Einlesen des Passworts")?,
|
||||||
|
);
|
||||||
|
|
||||||
if new_password.trim().is_empty() {
|
if new_password.trim().is_empty() {
|
||||||
bail!("Das Master-Passwort darf nicht leer sein.");
|
bail!("Das Master-Passwort darf nicht leer sein.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let confirm_password = rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
|
let confirm_password = Zeroizing::new(
|
||||||
.context("Fehler beim Einlesen der Passwort-Bestätigung")?;
|
rpassword::prompt_password("Neues Master-Passwort bestätigen: ")
|
||||||
|
.context("Fehler beim Einlesen der Passwort-Bestätigung")?,
|
||||||
|
);
|
||||||
|
|
||||||
if new_password != confirm_password {
|
if *new_password != *confirm_password {
|
||||||
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
|
bail!("Die eingegebenen Passwörter stimmen nicht überein!");
|
||||||
}
|
}
|
||||||
|
|
||||||
ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab...");
|
ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab...");
|
||||||
restore_header_from_recovery_key(container_path, key, &new_password)?;
|
restore_slot_from_recovery_key(container_path, key, &new_password, slot)?;
|
||||||
ui::step(2, 2, "💾", "Header mit neuem Passwort neu synthetisiert!");
|
ui::step(2, 2, "💾", &format!("Header für Slot {} mit neuem Passwort neu synthetisiert!", slot));
|
||||||
println!();
|
println!();
|
||||||
println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!"));
|
println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!"));
|
||||||
} else {
|
} else {
|
||||||
@@ -633,8 +751,10 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> {
|
|||||||
println!(" Container: {}", container_path.display());
|
println!(" Container: {}", container_path.display());
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
let password = rpassword::prompt_password("Master-Passwort eingeben: ")
|
let password = Zeroizing::new(
|
||||||
.context("Fehler beim Einlesen des Passworts")?;
|
rpassword::prompt_password("Master-Passwort eingeben: ")
|
||||||
|
.context("Fehler beim Einlesen des Passworts")?,
|
||||||
|
);
|
||||||
|
|
||||||
let db = Database::open(container_path)
|
let db = Database::open(container_path)
|
||||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||||
@@ -666,8 +786,10 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
|
|||||||
println!(" Container: {}", container_path.display());
|
println!(" Container: {}", container_path.display());
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
let password = rpassword::prompt_password("Master-Passwort für Vollprüfung (Enter für Strukturprüfung): ")
|
let password = Zeroizing::new(
|
||||||
.context("Fehler beim Einlesen des Passworts")?;
|
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 dek = if !password.trim().is_empty() {
|
||||||
let db = Database::open(container_path).context("Konnte Container nicht öffnen")?;
|
let db = Database::open(container_path).context("Konnte Container nicht öffnen")?;
|
||||||
@@ -715,6 +837,12 @@ async fn run() -> Result<()> {
|
|||||||
Commands::Compact { path, pages } => {
|
Commands::Compact { path, pages } => {
|
||||||
handle_compact(&path, pages)?;
|
handle_compact(&path, pages)?;
|
||||||
}
|
}
|
||||||
|
Commands::Backup { path, output } => {
|
||||||
|
handle_backup(&path, &output)?;
|
||||||
|
}
|
||||||
|
Commands::Restore { path, output } => {
|
||||||
|
handle_restore(&path, &output)?;
|
||||||
|
}
|
||||||
Commands::Mount {
|
Commands::Mount {
|
||||||
path,
|
path,
|
||||||
drive,
|
drive,
|
||||||
@@ -739,14 +867,16 @@ async fn run() -> Result<()> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let auth = if let Some(key) = recovery_key {
|
let auth = if let Some(key) = recovery_key {
|
||||||
ContainerAuth::RecoveryKey(key)
|
ContainerAuth::RecoveryKey(Zeroizing::new(key))
|
||||||
} else {
|
} 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 = Zeroizing::new(
|
||||||
.context("Fehler beim Einlesen des Passworts")?;
|
rpassword::prompt_password(prompt_text)
|
||||||
|
.context("Fehler beim Einlesen des Passworts")?,
|
||||||
|
);
|
||||||
ContainerAuth::Password(password)
|
ContainerAuth::Password(password)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -786,8 +916,9 @@ async fn run() -> Result<()> {
|
|||||||
path,
|
path,
|
||||||
header_file,
|
header_file,
|
||||||
recovery_key,
|
recovery_key,
|
||||||
|
slot,
|
||||||
} => {
|
} => {
|
||||||
handle_restore_header(&path, header_file.as_deref(), recovery_key.as_deref())?;
|
handle_restore_header(&path, header_file.as_deref(), recovery_key.as_deref(), slot)?;
|
||||||
}
|
}
|
||||||
Commands::RecoveryKey { path } => {
|
Commands::RecoveryKey { path } => {
|
||||||
handle_recovery_key(&path)?;
|
handle_recovery_key(&path)?;
|
||||||
|
|||||||
+11
-4
@@ -13,6 +13,7 @@ use hyper_util::rt::TokioIo;
|
|||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use crate::crypto::mnemonic_to_dek;
|
use crate::crypto::mnemonic_to_dek;
|
||||||
use crate::storage::Database;
|
use crate::storage::Database;
|
||||||
@@ -22,8 +23,8 @@ use crate::vfs::SanctumFs;
|
|||||||
/// Authentifizierungsmethode für das Einbinden eines Containers: Entweder Master-Passwort oder 24-Wort Notfallschlüssel.
|
/// Authentifizierungsmethode für das Einbinden eines Containers: Entweder Master-Passwort oder 24-Wort Notfallschlüssel.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ContainerAuth {
|
pub enum ContainerAuth {
|
||||||
Password(String),
|
Password(Zeroizing<String>),
|
||||||
RecoveryKey(String),
|
RecoveryKey(Zeroizing<String>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hilfsfunktion zur Formatierung des Laufwerksbuchstabens (z. B. 'S' -> "S:")
|
/// Hilfsfunktion zur Formatierung des Laufwerksbuchstabens (z. B. 'S' -> "S:")
|
||||||
@@ -66,11 +67,17 @@ fn run_net_use_mount(drive_str: &str, port: u16) -> Result<()> {
|
|||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let webclient_hint = if stderr.contains("67") || stderr.contains("Netzwerkname") || stderr.contains("Systemfehler") {
|
||||||
|
"\n\nHinweis: Das Einbinden von Netzlaufwerken erfordert den Windows-Dienst 'WebClient'. Prüfen Sie in einer Administrator-Konsole: 'net start WebClient'."
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
bail!(
|
bail!(
|
||||||
"Laufwerk {} konnte nicht eingebunden werden:\n{}{}",
|
"Laufwerk {} konnte nicht eingebunden werden:\n{}{}{}",
|
||||||
drive_str,
|
drive_str,
|
||||||
stdout,
|
stdout,
|
||||||
stderr
|
stderr,
|
||||||
|
webclient_hint
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+75
-30
@@ -6,7 +6,8 @@ use anyhow::{bail, Context, Result};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::crypto::{
|
use crate::crypto::{
|
||||||
derive_kek, mnemonic_to_dek, wrap_dek, KdfParams, FORMAT_VERSION,
|
derive_kek, generate_dummy_slot, mnemonic_to_dek, wrap_slot0_payload,
|
||||||
|
wrap_slot1_payload, KdfParams, FORMAT_VERSION,
|
||||||
};
|
};
|
||||||
use crate::storage::{ContainerMeta, Database, SlotMeta};
|
use crate::storage::{ContainerMeta, Database, SlotMeta};
|
||||||
|
|
||||||
@@ -221,12 +222,13 @@ pub fn restore_header_backup(container_path: &Path, backup_path: &Path) -> Resul
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rekonstruiert den Container-Header vollständig mithilfe des 24-Wort BIP-39 Notfallschlüssels
|
/// Rekonstruiert einen Slot des Container-Headers mithilfe des 24-Wort BIP-39 Notfallschlüssels
|
||||||
/// und initialisiert ein neues Master-Passwort.
|
/// und initialisiert ein neues Master-Passwort für den entsprechenden Slot (0 = Decoy, 1 = Hidden Vault).
|
||||||
pub fn restore_header_from_recovery_key(
|
pub fn restore_slot_from_recovery_key(
|
||||||
container_path: &Path,
|
container_path: &Path,
|
||||||
recovery_key: &str,
|
recovery_key: &str,
|
||||||
new_password: &str,
|
new_password: &str,
|
||||||
|
target_slot_id: u32,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if !container_path.exists() {
|
if !container_path.exists() {
|
||||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||||
@@ -248,39 +250,73 @@ pub fn restore_header_from_recovery_key(
|
|||||||
let kek = derive_kek(new_password, &salt, &kdf_params)
|
let kek = derive_kek(new_password, &salt, &kdf_params)
|
||||||
.context("Schlüsselableitung für neues Passwort fehlgeschlagen")?;
|
.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 slot0 = SlotMeta {
|
|
||||||
slot_id: 0,
|
|
||||||
version: FORMAT_VERSION,
|
|
||||||
kdf_salt: salt,
|
|
||||||
kdf_params: kdf_params.clone(),
|
|
||||||
wrapped_dek: wrapped_dek.clone(),
|
|
||||||
header_nonce,
|
|
||||||
header_tag,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 4. In Container schreiben (bestehenden Slot 1 wie Hidden Vault oder Dummy bewahren)
|
|
||||||
let db = Database::open(container_path)
|
let db = Database::open(container_path)
|
||||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||||
|
|
||||||
let mut slots = vec![slot0];
|
let carrier_node_id = db.find_carrier_node_id()?.unwrap_or(0);
|
||||||
if let Ok(existing_slots) = db.read_slots() {
|
|
||||||
if let Some(s1) = existing_slots.into_iter().find(|s| s.slot_id == 1) {
|
let mut existing_slots = db.read_slots().unwrap_or_default();
|
||||||
slots.push(s1);
|
|
||||||
|
let new_slot = if target_slot_id == 1 {
|
||||||
|
// Für Slot 1 wird DEK_0 benötigt (z. B. aus vorhandenem Slot 0)
|
||||||
|
let dek_0_bytes = [0u8; 32];
|
||||||
|
let (wrapped, nonce, tag) = wrap_slot1_payload(&kek, &dek, &dek_0_bytes, carrier_node_id)?;
|
||||||
|
SlotMeta {
|
||||||
|
slot_id: 1,
|
||||||
|
version: FORMAT_VERSION,
|
||||||
|
kdf_salt: salt,
|
||||||
|
kdf_params: kdf_params.clone(),
|
||||||
|
wrapped_dek: wrapped,
|
||||||
|
header_nonce: nonce,
|
||||||
|
header_tag: tag,
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Slot 0 (Standard / Decoy Vault) mit 40 Bytes für Modell A
|
||||||
|
let (wrapped, nonce, tag) = wrap_slot0_payload(&kek, &dek, carrier_node_id)?;
|
||||||
|
SlotMeta {
|
||||||
|
slot_id: 0,
|
||||||
|
version: FORMAT_VERSION,
|
||||||
|
kdf_salt: salt,
|
||||||
|
kdf_params: kdf_params.clone(),
|
||||||
|
wrapped_dek: wrapped,
|
||||||
|
header_nonce: nonce,
|
||||||
|
header_tag: tag,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Slot ersetzen bzw. einfügen
|
||||||
|
existing_slots.retain(|s| s.slot_id != target_slot_id);
|
||||||
|
existing_slots.push(new_slot);
|
||||||
|
existing_slots.sort_by_key(|s| s.slot_id);
|
||||||
|
|
||||||
|
// Falls Slot 1 fehlt, Dummy-Slot 1 ergänzen für Plausible Deniability
|
||||||
|
if !existing_slots.iter().any(|s| s.slot_id == 1) {
|
||||||
|
let (dummy_dek, dummy_nonce, dummy_tag, dummy_salt) = generate_dummy_slot();
|
||||||
|
let dummy_slot = SlotMeta {
|
||||||
|
slot_id: 1,
|
||||||
|
version: FORMAT_VERSION,
|
||||||
|
kdf_salt: dummy_salt,
|
||||||
|
kdf_params: KdfParams::default(),
|
||||||
|
wrapped_dek: dummy_dek,
|
||||||
|
header_nonce: dummy_nonce,
|
||||||
|
header_tag: dummy_tag,
|
||||||
|
};
|
||||||
|
existing_slots.push(dummy_slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let slot0_meta = existing_slots
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.slot_id == 0)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Slot 0 fehlt im Header"))?;
|
||||||
|
|
||||||
let meta = ContainerMeta {
|
let meta = ContainerMeta {
|
||||||
version: FORMAT_VERSION,
|
version: FORMAT_VERSION,
|
||||||
kdf_salt: salt,
|
kdf_salt: slot0_meta.kdf_salt,
|
||||||
kdf_params,
|
kdf_params: slot0_meta.kdf_params.clone(),
|
||||||
wrapped_dek,
|
wrapped_dek: slot0_meta.wrapped_dek.clone(),
|
||||||
header_nonce,
|
header_nonce: slot0_meta.header_nonce,
|
||||||
header_tag,
|
header_tag: slot0_meta.header_tag,
|
||||||
slots,
|
slots: existing_slots,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.restore_meta(&meta)
|
db.restore_meta(&meta)
|
||||||
@@ -291,10 +327,19 @@ pub fn restore_header_from_recovery_key(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rekonstruiert den Decoy-Slot (Slot 0) mithilfe des 24-Wort BIP-39 Notfallschlüssels.
|
||||||
|
pub fn restore_header_from_recovery_key(
|
||||||
|
container_path: &Path,
|
||||||
|
recovery_key: &str,
|
||||||
|
new_password: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
restore_slot_from_recovery_key(container_path, recovery_key, new_password, 0)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::crypto::{generate_dek, generate_salt, unwrap_dek};
|
use crate::crypto::{generate_dek, generate_salt, unwrap_dek, wrap_dek};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -253,6 +253,16 @@ impl Database {
|
|||||||
Ok(count as usize)
|
Ok(count as usize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sucht nach einem existierenden Carrier-Knoten im Decoy-Wurzelverzeichnis (parent_id = 1, is_dir = 0).
|
||||||
|
pub fn find_carrier_node_id(&self) -> Result<Option<i64>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id FROM nodes WHERE parent_id = 1 AND is_dir = 0 ORDER BY id ASC LIMIT 1",
|
||||||
|
)?;
|
||||||
|
let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
/// Überschreibt Chunks eines Knotens vor dem Löschen mit kryptografischem Zufallsrauschen (Chunk Shredding).
|
/// Überschreibt Chunks eines Knotens vor dem Löschen mit kryptografischem Zufallsrauschen (Chunk Shredding).
|
||||||
pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> {
|
pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
@@ -459,6 +469,7 @@ impl Database {
|
|||||||
let mut dummy_noise = vec![0u8; CHUNK_SIZE];
|
let mut dummy_noise = vec![0u8; CHUNK_SIZE];
|
||||||
OsRng.fill_bytes(&mut dummy_noise);
|
OsRng.fill_bytes(&mut dummy_noise);
|
||||||
|
|
||||||
|
conn.execute_batch("BEGIN TRANSACTION;")?;
|
||||||
for b in 1..total_blocks {
|
for b in 1..total_blocks {
|
||||||
let (ct, nonce, tag) = crate::crypto::encrypt_chunk(
|
let (ct, nonce, tag) = crate::crypto::encrypt_chunk(
|
||||||
dek_0,
|
dek_0,
|
||||||
@@ -474,7 +485,12 @@ impl Database {
|
|||||||
tag.as_slice(),
|
tag.as_slice(),
|
||||||
ct,
|
ct,
|
||||||
])?;
|
])?;
|
||||||
|
|
||||||
|
if b % 500 == 0 {
|
||||||
|
conn.execute_batch("COMMIT; BEGIN TRANSACTION;")?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
conn.execute_batch("COMMIT;")?;
|
||||||
|
|
||||||
Some(c_id)
|
Some(c_id)
|
||||||
} else {
|
} else {
|
||||||
@@ -1204,6 +1220,61 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Erstellt ein konsistentes Online-Live-Backup der gesamten Container-Datenbank via SQLite Online Backup API.
|
||||||
|
/// Kann auch während eines aktiven WebDAV-Mounts ohne Lese-/Schreibkonflikte ausgeführt werden.
|
||||||
|
pub fn online_backup<P: AsRef<Path>>(&self, dest_path: P) -> Result<()> {
|
||||||
|
let dest_path = dest_path.as_ref();
|
||||||
|
if let Some(parent) = dest_path.parent() {
|
||||||
|
if !parent.as_os_str().is_empty() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut dest_conn = Connection::open(dest_path)?;
|
||||||
|
let src_conn = self.conn.lock().unwrap();
|
||||||
|
|
||||||
|
let backup = rusqlite::backup::Backup::new(&src_conn, &mut dest_conn)?;
|
||||||
|
backup.run_to_completion(100, std::time::Duration::from_millis(20), None)?;
|
||||||
|
drop(backup);
|
||||||
|
|
||||||
|
dest_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stellt einen Container vollständig aus einer Sicherungskopie wieder her und verifiziert die Konsistenz.
|
||||||
|
pub fn restore_from_backup<P: AsRef<Path>>(backup_path: P, dest_path: P) -> Result<()> {
|
||||||
|
let backup_path = backup_path.as_ref();
|
||||||
|
let dest_path = dest_path.as_ref();
|
||||||
|
|
||||||
|
if !backup_path.exists() {
|
||||||
|
bail!("Backup-Datei '{}' existiert nicht.", backup_path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = dest_path.parent() {
|
||||||
|
if !parent.as_os_str().is_empty() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let src_conn = Connection::open(backup_path)?;
|
||||||
|
let mut dest_conn = Connection::open(dest_path)?;
|
||||||
|
|
||||||
|
let backup = rusqlite::backup::Backup::new(&src_conn, &mut dest_conn)?;
|
||||||
|
backup.run_to_completion(100, std::time::Duration::from_millis(20), None)?;
|
||||||
|
drop(backup);
|
||||||
|
drop(src_conn);
|
||||||
|
|
||||||
|
dest_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
|
||||||
|
|
||||||
|
// B-Tree Integritätsprüfung
|
||||||
|
let check: String = dest_conn.query_row("PRAGMA quick_check;", [], |r| r.get(0))?;
|
||||||
|
if check != "ok" {
|
||||||
|
bail!("Integritätsprüfung des wiederhergestellten Containers fehlgeschlagen: {check}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Schreibt oder stellt die Metadaten in der `meta`-Tabelle wieder her (z. B. nach Restore oder Header-Neugenerierung).
|
/// 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<()> {
|
pub fn restore_meta(&self, meta: &ContainerMeta) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
|
|||||||
+5
-5
@@ -135,6 +135,7 @@ impl SanctumFile {
|
|||||||
dek: Arc<Zeroizing<[u8; 32]>>,
|
dek: Arc<Zeroizing<[u8; 32]>>,
|
||||||
format_version: u32,
|
format_version: u32,
|
||||||
last_activity: Arc<AtomicU64>,
|
last_activity: Arc<AtomicU64>,
|
||||||
|
append: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let meta = SanctumMetaData {
|
let meta = SanctumMetaData {
|
||||||
is_dir: node.is_dir,
|
is_dir: node.is_dir,
|
||||||
@@ -143,10 +144,12 @@ impl SanctumFile {
|
|||||||
modified_at: UNIX_EPOCH + Duration::from_secs(node.modified_at),
|
modified_at: UNIX_EPOCH + Duration::from_secs(node.modified_at),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let cursor = if append { node.size } else { 0 };
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
node_id: node.id,
|
node_id: node.id,
|
||||||
file_size: node.size,
|
file_size: node.size,
|
||||||
cursor: 0,
|
cursor,
|
||||||
db,
|
db,
|
||||||
dek,
|
dek,
|
||||||
meta,
|
meta,
|
||||||
@@ -634,6 +637,7 @@ impl DavFileSystem for SanctumFs {
|
|||||||
self.dek.clone(),
|
self.dek.clone(),
|
||||||
self.format_version,
|
self.format_version,
|
||||||
self.last_activity.clone(),
|
self.last_activity.clone(),
|
||||||
|
options.append,
|
||||||
);
|
);
|
||||||
Ok(Box::new(file) as Box<dyn DavFile>)
|
Ok(Box::new(file) as Box<dyn DavFile>)
|
||||||
})
|
})
|
||||||
@@ -649,7 +653,6 @@ impl DavFileSystem for SanctumFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
self.touch();
|
|
||||||
let path_str = Self::path_to_str(path);
|
let path_str = Self::path_to_str(path);
|
||||||
let node = self
|
let node = self
|
||||||
.resolve_path(&path_str)?
|
.resolve_path(&path_str)?
|
||||||
@@ -688,9 +691,6 @@ impl DavFileSystem for SanctumFs {
|
|||||||
|
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let path_str = Self::path_to_str(path);
|
let path_str = Self::path_to_str(path);
|
||||||
if path_str != "/" && !path_str.is_empty() {
|
|
||||||
self.touch();
|
|
||||||
}
|
|
||||||
let node = self
|
let node = self
|
||||||
.resolve_path(&path_str)?
|
.resolve_path(&path_str)?
|
||||||
.ok_or(FsError::NotFound)?;
|
.ok_or(FsError::NotFound)?;
|
||||||
|
|||||||
@@ -358,3 +358,115 @@ async fn test_model_a_container_file_size_invariance() {
|
|||||||
|
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_carrier_file_drop_and_append_mode() {
|
||||||
|
let path = temp_db_path("carrier_drop_append");
|
||||||
|
let carrier_size_bytes = 10 * 1024 * 1024; // 10 MB
|
||||||
|
let carrier_name = "test_carrier.iso";
|
||||||
|
|
||||||
|
let pass_decoy = "DecoyPassword2026!";
|
||||||
|
let pass_hidden = "HiddenSecretPassword2026!";
|
||||||
|
|
||||||
|
let kdf_params = KdfParams {
|
||||||
|
memory_cost: 1024,
|
||||||
|
time_cost: 1,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let salt_0 = generate_salt();
|
||||||
|
let kek_0 = derive_kek(pass_decoy, &salt_0, &kdf_params).unwrap();
|
||||||
|
let dek_0 = generate_dek();
|
||||||
|
|
||||||
|
let salt_1 = generate_salt();
|
||||||
|
let kek_1 = derive_kek(pass_hidden, &salt_1, &kdf_params).unwrap();
|
||||||
|
let dek_1 = generate_dek();
|
||||||
|
|
||||||
|
let carrier_node_id = 3i64;
|
||||||
|
let (wrapped_0, nonce_0, tag_0) =
|
||||||
|
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
||||||
|
let (wrapped_1, nonce_1, tag_1) =
|
||||||
|
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id).unwrap();
|
||||||
|
|
||||||
|
let db = Database::open(&path).expect("Open database");
|
||||||
|
|
||||||
|
db.init_schema_with_carrier(
|
||||||
|
&salt_0,
|
||||||
|
&kdf_params,
|
||||||
|
&wrapped_0,
|
||||||
|
&nonce_0,
|
||||||
|
&tag_0,
|
||||||
|
Some((
|
||||||
|
carrier_name,
|
||||||
|
carrier_size_bytes,
|
||||||
|
&salt_1,
|
||||||
|
&kdf_params,
|
||||||
|
&wrapped_1,
|
||||||
|
&nonce_1,
|
||||||
|
&tag_1,
|
||||||
|
&dek_0,
|
||||||
|
&dek_1,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.expect("Init carrier schema");
|
||||||
|
|
||||||
|
db.checkpoint().unwrap();
|
||||||
|
|
||||||
|
let meta = db.read_meta().unwrap();
|
||||||
|
let auth_hidden = meta.authenticate(pass_hidden).expect("Auth hidden");
|
||||||
|
|
||||||
|
let hidden_fs = SanctumFs::with_carrier(
|
||||||
|
db.clone(),
|
||||||
|
auth_hidden.dek().clone(),
|
||||||
|
auth_hidden.carrier_dek(),
|
||||||
|
auth_hidden.carrier_node_id(),
|
||||||
|
auth_hidden.version(),
|
||||||
|
true,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. TEST CarrierFile::drop: Write bytes OHNE expliziten flush(), dann drop(handle)
|
||||||
|
let test_file = DavPath::new("/drop_flush_test.txt").unwrap();
|
||||||
|
let mut write_handle = hidden_fs
|
||||||
|
.open(&test_file, OpenOptions { create: true, write: true, ..Default::default() })
|
||||||
|
.await
|
||||||
|
.expect("Open file for write");
|
||||||
|
|
||||||
|
let initial_data = b"Hello from unflushed write!";
|
||||||
|
write_handle.write_bytes(Bytes::from_static(initial_data)).await.expect("Write initial data");
|
||||||
|
// WICHTIG: KEIN write_handle.flush()! Nur drop:
|
||||||
|
drop(write_handle);
|
||||||
|
|
||||||
|
// Jetzt Datei wieder lesend öffnen und prüfen, ob Daten durch Drop persistiert wurden
|
||||||
|
let mut read_handle = hidden_fs
|
||||||
|
.open(&test_file, OpenOptions { read: true, ..Default::default() })
|
||||||
|
.await
|
||||||
|
.expect("Open file for read");
|
||||||
|
let read_back = read_handle.read_bytes(100).await.expect("Read data back");
|
||||||
|
assert_eq!(&read_back[..], initial_data, "Drop muss ungeflushte Datenblöcke und Inode automatisch sichern");
|
||||||
|
drop(read_handle);
|
||||||
|
|
||||||
|
// 2. TEST O_APPEND: Im Append-Modus öffnen und weitere Daten anhängen
|
||||||
|
let append_data = b" - Appended data at EOF!";
|
||||||
|
let mut append_handle = hidden_fs
|
||||||
|
.open(&test_file, OpenOptions { write: true, append: true, ..Default::default() })
|
||||||
|
.await
|
||||||
|
.expect("Open file for append");
|
||||||
|
|
||||||
|
append_handle.write_bytes(Bytes::from_static(append_data)).await.expect("Write appended data");
|
||||||
|
drop(append_handle); // Drop sichert auch hier
|
||||||
|
|
||||||
|
// Prüfe den vollständigen Dateiinhalt nach Append
|
||||||
|
let mut read_handle_2 = hidden_fs
|
||||||
|
.open(&test_file, OpenOptions { read: true, ..Default::default() })
|
||||||
|
.await
|
||||||
|
.expect("Open file for read after append");
|
||||||
|
let full_content = read_handle_2.read_bytes(200).await.expect("Read full content");
|
||||||
|
let mut expected = Vec::new();
|
||||||
|
expected.extend_from_slice(initial_data);
|
||||||
|
expected.extend_from_slice(append_data);
|
||||||
|
assert_eq!(&full_content[..], &expected[..], "O_APPEND muss Daten am Dateiende anhängen");
|
||||||
|
drop(read_handle_2);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|||||||
@@ -974,6 +974,82 @@ async fn test_plausible_deniability_phase1_indistinguishability_and_safeguards()
|
|||||||
let _ = std::fs::remove_file(&backup_path);
|
let _ = std::fs::remove_file(&backup_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sanctum_online_backup_and_restore() {
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let container_path = temp_dir.join(format!("test_backup_src_{}.sanctum", std::process::id()));
|
||||||
|
let backup_path = temp_dir.join(format!("test_backup_out_{}.sanctum.bak", std::process::id()));
|
||||||
|
let restored_path = temp_dir.join(format!("test_backup_restored_{}.sanctum", std::process::id()));
|
||||||
|
|
||||||
|
for p in [&container_path, &backup_path, &restored_path] {
|
||||||
|
if p.exists() {
|
||||||
|
let _ = std::fs::remove_file(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = "BackupTestPassword2026!";
|
||||||
|
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, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap();
|
||||||
|
|
||||||
|
let db = Database::open(&container_path).unwrap();
|
||||||
|
db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag).unwrap();
|
||||||
|
db.checkpoint().unwrap();
|
||||||
|
|
||||||
|
// Datei schreiben
|
||||||
|
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
|
||||||
|
let test_file = DavPath::new("/important.txt").unwrap();
|
||||||
|
let mut file = fs.open(&test_file, OpenOptions { write: true, create_new: true, ..Default::default() }).await.unwrap();
|
||||||
|
file.write_bytes(Bytes::from_static(b"Sanctum Online Backup Test Data")).await.unwrap();
|
||||||
|
file.flush().await.unwrap();
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
// 1. Online-Live-Backup erstellen
|
||||||
|
db.online_backup(&backup_path).expect("Online backup should succeed");
|
||||||
|
assert!(backup_path.exists(), "Backup-Datei muss existieren");
|
||||||
|
|
||||||
|
// 2. Original-Container verändern (neue Datei hinzufügen)
|
||||||
|
let extra_file = DavPath::new("/extra_after_backup.txt").unwrap();
|
||||||
|
let mut file2 = fs.open(&extra_file, OpenOptions { write: true, create_new: true, ..Default::default() }).await.unwrap();
|
||||||
|
file2.write_bytes(Bytes::from_static(b"After Backup Data")).await.unwrap();
|
||||||
|
file2.flush().await.unwrap();
|
||||||
|
drop(file2);
|
||||||
|
|
||||||
|
// 3. Restore aus dem Backup in neuen Pfad
|
||||||
|
Database::restore_from_backup(&backup_path, &restored_path).expect("Restore should succeed");
|
||||||
|
assert!(restored_path.exists(), "Wiederhergestellter Container muss existieren");
|
||||||
|
|
||||||
|
// 4. Verifiziere den wiederhergestellten Container
|
||||||
|
let restored_db = Database::open(&restored_path).unwrap();
|
||||||
|
let meta = restored_db.read_meta().unwrap();
|
||||||
|
let auth = meta.authenticate(password).expect("Passwort muss den wiederhergestellten Container entsperren");
|
||||||
|
assert_eq!(*auth.0, *dek);
|
||||||
|
|
||||||
|
let restored_fs = SanctumFs::new(restored_db.clone(), auth.0, meta.version);
|
||||||
|
// /important.txt muss existieren und den korrekten Inhalt haben
|
||||||
|
let mut read_handle = restored_fs.open(&test_file, OpenOptions { read: true, ..Default::default() }).await.unwrap();
|
||||||
|
let content = read_handle.read_bytes(100).await.unwrap();
|
||||||
|
assert_eq!(&content[..], b"Sanctum Online Backup Test Data");
|
||||||
|
drop(read_handle);
|
||||||
|
|
||||||
|
// /extra_after_backup.txt darf im Backup-Zustand NICHT existieren
|
||||||
|
assert!(restored_fs.open(&extra_file, OpenOptions { read: true, ..Default::default() }).await.is_err());
|
||||||
|
|
||||||
|
// Integritätsprüfung (FSCK) auf wiederhergestelltem Container
|
||||||
|
let report = verify_container(&restored_path, Some(&dek), true).unwrap();
|
||||||
|
assert!(report.is_healthy());
|
||||||
|
|
||||||
|
for p in [&container_path, &backup_path, &restored_path] {
|
||||||
|
let _ = std::fs::remove_file(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user