release: v0.7.2 — Security Audit Remediation (SA-01 bis SA-07)
- SA-01: Container-DoS / KDF-Amplification Schutz mit Pre-KDF Validierung, max 2 Slots (nur 0 und 1), Slot 0 Pflicht und strikten BLOB-Laengen - SA-02: Release-Signierung in CI entkoppelt (getrennte build und sign-and-release Jobs, Secret-Isolation) - SA-03: Pinned Download-Integritaet fuer minisign.exe in CI via SHA-256 - SA-04: Immutable Action-Pinning (@sha) und Toolchain-Pinning (1.85.0) in CI - SA-05: Session-Token vollstaendig aus URIs verbannt (403 Forbidden bei Vorkommen im Pfad/Query) - SA-06: Constant-Time Token- und Auth-Vergleiche via subtle::ConstantTimeEq - SA-07: Dokumentations-Klarstellung bzgl. logischem Shredding vs. physischer SSD/FTL/CoW-Persistenz
This commit is contained in:
+11
-18
@@ -10,8 +10,8 @@ use bytes::{Buf, Bytes, BytesMut};
|
||||
use dav_server::{
|
||||
davpath::DavPath,
|
||||
fs::{
|
||||
DavDirEntry, DavFile, DavFileSystem, DavMetaData, FsError, FsFuture, FsStream,
|
||||
OpenOptions, ReadDirMeta,
|
||||
DavDirEntry, DavFile, DavFileSystem, DavMetaData, FsError, FsFuture, FsStream, OpenOptions,
|
||||
ReadDirMeta,
|
||||
},
|
||||
};
|
||||
use futures_util::stream;
|
||||
@@ -624,9 +624,7 @@ impl DavFileSystem for CarrierFs {
|
||||
};
|
||||
|
||||
inner.manifest.inodes.insert(new_id, new_dir);
|
||||
inner
|
||||
.save_manifest()
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -659,9 +657,7 @@ impl DavFileSystem for CarrierFs {
|
||||
}
|
||||
|
||||
inner.manifest.inodes.remove(&node.id);
|
||||
inner
|
||||
.save_manifest()
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -690,9 +686,7 @@ impl DavFileSystem for CarrierFs {
|
||||
}
|
||||
|
||||
inner.manifest.inodes.remove(&node.id);
|
||||
inner
|
||||
.save_manifest()
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -748,9 +742,7 @@ impl DavFileSystem for CarrierFs {
|
||||
inode.modified_at = now;
|
||||
}
|
||||
|
||||
inner
|
||||
.save_manifest()
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
@@ -1011,9 +1003,7 @@ impl DavFile for CarrierFile {
|
||||
inode.modified_at = now;
|
||||
}
|
||||
|
||||
inner
|
||||
.save_manifest()
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
inner.save_manifest().map_err(|_| FsError::GeneralFailure)?;
|
||||
|
||||
self.meta.size = self.file_size;
|
||||
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
|
||||
@@ -1025,7 +1015,10 @@ 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);
|
||||
tracing::warn!(
|
||||
"Fehler beim automatischen Flush im CarrierFile::drop: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
if let Some((_, ref mut data, _)) = self.cached_block {
|
||||
data.zeroize();
|
||||
|
||||
+185
-63
@@ -101,11 +101,7 @@ pub fn check_password_prefix_collision(pass0: &str, pass1: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Leitet aus dem Master-Passwort und dem Salt einen 256-Bit Key Encryption Key (KEK) via Argon2id ab.
|
||||
pub fn derive_kek(
|
||||
password: &str,
|
||||
salt: &[u8],
|
||||
params: &KdfParams,
|
||||
) -> Result<Zeroizing<[u8; 32]>> {
|
||||
pub fn derive_kek(password: &str, salt: &[u8], params: &KdfParams) -> Result<Zeroizing<[u8; 32]>> {
|
||||
validate_kdf_params(params)?;
|
||||
|
||||
let argon2_params = Params::new(
|
||||
@@ -145,10 +141,7 @@ pub fn generate_salt() -> [u8; 16] {
|
||||
}
|
||||
|
||||
/// Verschlüsselt beliebige Schlüsseldaten (32B DEK, 40B Slot0-Payload oder 72B Slot1-Payload) via AES-256-GCM.
|
||||
pub fn wrap_key_payload(
|
||||
kek: &[u8; 32],
|
||||
payload: &[u8],
|
||||
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
||||
pub fn wrap_key_payload(kek: &[u8; 32], payload: &[u8]) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
||||
let cipher = Aes256Gcm::new_from_slice(kek)
|
||||
.map_err(|e| anyhow::anyhow!("AES-GCM Initialisierungsfehler: {e}"))?;
|
||||
|
||||
@@ -183,17 +176,18 @@ pub fn unwrap_key_payload(
|
||||
let mut buffer = wrapped_payload.to_vec();
|
||||
cipher
|
||||
.decrypt_in_place_detached(nonce, b"SANCTUM_HEADER_DEK", &mut buffer, tag)
|
||||
.map_err(|_| anyhow::anyhow!("Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"))?;
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Passwort falsch oder Header beschädigt (AEAD Authentifizierungsfehler)"
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Zeroizing::new(buffer))
|
||||
}
|
||||
|
||||
/// Verschlüsselt den DEK (32 Bytes) mit dem KEK via AES-256-GCM.
|
||||
/// Gibt (wrapped_dek_32_bytes, nonce_12_bytes, tag_16_bytes) zurück.
|
||||
pub fn wrap_dek(
|
||||
kek: &[u8; 32],
|
||||
dek: &[u8; 32],
|
||||
) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
||||
pub fn wrap_dek(kek: &[u8; 32], dek: &[u8; 32]) -> Result<(Vec<u8>, [u8; 12], [u8; 16])> {
|
||||
wrap_key_payload(kek, dek)
|
||||
}
|
||||
|
||||
@@ -206,7 +200,10 @@ pub fn unwrap_dek(
|
||||
) -> Result<Zeroizing<[u8; 32]>> {
|
||||
let payload = unwrap_key_payload(kek, wrapped_dek, nonce_bytes, tag_bytes)?;
|
||||
if payload.len() < 32 {
|
||||
bail!("Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}", payload.len());
|
||||
bail!(
|
||||
"Ungültige wrapped_dek Länge: erwartet mindestens 32 Bytes, erhalten {}",
|
||||
payload.len()
|
||||
);
|
||||
}
|
||||
|
||||
let mut dek = Zeroizing::new([0u8; 32]);
|
||||
@@ -265,7 +262,8 @@ pub fn build_name_aad(parent_id: i64) -> [u8; 16] {
|
||||
aad
|
||||
}
|
||||
|
||||
static ALLOW_LEGACY_NAMES: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
static ALLOW_LEGACY_NAMES: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// Aktiviert oder deaktiviert den veralteten AAD-Fallback für Dateinamen (S-10).
|
||||
pub fn set_allow_legacy_names(allow: bool) {
|
||||
@@ -289,7 +287,11 @@ pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
|
||||
};
|
||||
let mut buffer = name.as_bytes().to_vec();
|
||||
let aad = build_name_aad(parent_id);
|
||||
let tag = match cipher.encrypt_in_place_detached(Nonce::from_slice(&nonce_bytes), &aad, &mut buffer) {
|
||||
let tag = match cipher.encrypt_in_place_detached(
|
||||
Nonce::from_slice(&nonce_bytes),
|
||||
&aad,
|
||||
&mut buffer,
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
@@ -303,7 +305,12 @@ pub fn encrypt_node_name(dek: &[u8; 32], parent_id: i64, name: &str) -> String {
|
||||
/// Entschlüsselt den Dateinamen eines Knotens im Hidden Vault mit AES-256-GCM.
|
||||
/// Prüft primär die kryptografische Bindung an parent_id; bietet optional Fallback
|
||||
/// auf die statische AAD für ältere Container, wenn `allow_legacy` aktiv ist (S-10).
|
||||
pub fn decrypt_node_name_ext(dek: &[u8; 32], parent_id: i64, stored: &str, allow_legacy: bool) -> Option<String> {
|
||||
pub fn decrypt_node_name_ext(
|
||||
dek: &[u8; 32],
|
||||
parent_id: i64,
|
||||
stored: &str,
|
||||
allow_legacy: bool,
|
||||
) -> Option<String> {
|
||||
// Abwärtskompatibilität für alte v0.2.0 $h$<nonce>$<tag>$<ct> Namen
|
||||
if let Some(rest) = stored.strip_prefix("$h$") {
|
||||
let parts: Vec<&str> = rest.split('$').collect();
|
||||
@@ -423,7 +430,11 @@ pub fn levenshtein_distance(a: &str, b: &str) -> usize {
|
||||
|
||||
for i in 1..=m {
|
||||
for j in 1..=n {
|
||||
let cost = if a_chars[i - 1] == b_chars[j - 1] { 0 } else { 1 };
|
||||
let cost = if a_chars[i - 1] == b_chars[j - 1] {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
};
|
||||
dp[i][j] = (dp[i - 1][j] + 1)
|
||||
.min(dp[i][j - 1] + 1)
|
||||
.min(dp[i - 1][j - 1] + cost);
|
||||
@@ -471,10 +482,7 @@ pub fn normalize_mnemonic_phrase(phrase: &str) -> Vec<String> {
|
||||
})
|
||||
.collect();
|
||||
|
||||
cleaned
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
cleaned.split_whitespace().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK.
|
||||
@@ -500,9 +508,18 @@ pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
|
||||
if !word_list.contains(&word.as_str()) {
|
||||
let suggestion = suggest_bip39_word(word);
|
||||
let msg = if let Some(sug) = suggestion {
|
||||
format!("Wort #{} '{}' ist ungültig (Meinten Sie '{}'?)", idx + 1, word, sug)
|
||||
format!(
|
||||
"Wort #{} '{}' ist ungültig (Meinten Sie '{}'?)",
|
||||
idx + 1,
|
||||
word,
|
||||
sug
|
||||
)
|
||||
} else {
|
||||
format!("Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)", idx + 1, word)
|
||||
format!(
|
||||
"Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)",
|
||||
idx + 1,
|
||||
word
|
||||
)
|
||||
};
|
||||
invalid_words.push(msg);
|
||||
}
|
||||
@@ -620,7 +637,11 @@ pub fn decrypt_chunk(
|
||||
let mut buffer = ciphertext.to_vec();
|
||||
cipher
|
||||
.decrypt_in_place_detached(nonce, &aad, &mut buffer, tag)
|
||||
.map_err(|_| anyhow::anyhow!("Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"))?;
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Chunk-Integritätsprüfung fehlgeschlagen (AEAD Auth-Fehler oder Swap-Angriff)"
|
||||
)
|
||||
})?;
|
||||
|
||||
if format_version >= FORMAT_VERSION_V2 {
|
||||
if buffer.is_empty() {
|
||||
@@ -654,7 +675,6 @@ pub fn decrypt_chunk(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -700,26 +720,55 @@ mod tests {
|
||||
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V2).unwrap();
|
||||
|
||||
// Reguläre Entschlüsselung (v2)
|
||||
let decrypted =
|
||||
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
|
||||
let decrypted = decrypt_chunk(
|
||||
&dek,
|
||||
node_id,
|
||||
chunk_index,
|
||||
&ciphertext,
|
||||
&nonce,
|
||||
&tag,
|
||||
FORMAT_VERSION_V2,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
|
||||
// Swap Attack 1: Falsche node_id (Chunk in andere Datei verschoben)
|
||||
let swap_node_err =
|
||||
decrypt_chunk(&dek, 99i64, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2);
|
||||
let swap_node_err = decrypt_chunk(
|
||||
&dek,
|
||||
99i64,
|
||||
chunk_index,
|
||||
&ciphertext,
|
||||
&nonce,
|
||||
&tag,
|
||||
FORMAT_VERSION_V2,
|
||||
);
|
||||
assert!(swap_node_err.is_err());
|
||||
|
||||
// Swap Attack 2: Falscher chunk_index (Chunk innerhalb derselben Datei verschoben)
|
||||
let swap_idx_err =
|
||||
decrypt_chunk(&dek, node_id, 1u32, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2);
|
||||
let swap_idx_err = decrypt_chunk(
|
||||
&dek,
|
||||
node_id,
|
||||
1u32,
|
||||
&ciphertext,
|
||||
&nonce,
|
||||
&tag,
|
||||
FORMAT_VERSION_V2,
|
||||
);
|
||||
assert!(swap_idx_err.is_err());
|
||||
|
||||
// Manipulation des Ciphertexts
|
||||
let mut tampered_ct = ciphertext.clone();
|
||||
tampered_ct[0] ^= 0x01;
|
||||
assert!(
|
||||
decrypt_chunk(&dek, node_id, chunk_index, &tampered_ct, &nonce, &tag, FORMAT_VERSION_V2).is_err()
|
||||
);
|
||||
assert!(decrypt_chunk(
|
||||
&dek,
|
||||
node_id,
|
||||
chunk_index,
|
||||
&tampered_ct,
|
||||
&nonce,
|
||||
&tag,
|
||||
FORMAT_VERSION_V2
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -742,8 +791,16 @@ mod tests {
|
||||
plaintext.len()
|
||||
);
|
||||
|
||||
let decrypted =
|
||||
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V2).unwrap();
|
||||
let decrypted = decrypt_chunk(
|
||||
&dek,
|
||||
node_id,
|
||||
chunk_index,
|
||||
&ciphertext,
|
||||
&nonce,
|
||||
&tag,
|
||||
FORMAT_VERSION_V2,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
@@ -754,8 +811,7 @@ mod tests {
|
||||
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();
|
||||
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);
|
||||
|
||||
@@ -775,8 +831,16 @@ mod tests {
|
||||
encrypt_chunk(&dek, node_id, chunk_index, plaintext, FORMAT_VERSION_V1).unwrap();
|
||||
assert_eq!(ciphertext.len(), plaintext.len());
|
||||
|
||||
let decrypted =
|
||||
decrypt_chunk(&dek, node_id, chunk_index, &ciphertext, &nonce, &tag, FORMAT_VERSION_V1).unwrap();
|
||||
let decrypted = decrypt_chunk(
|
||||
&dek,
|
||||
node_id,
|
||||
chunk_index,
|
||||
&ciphertext,
|
||||
&nonce,
|
||||
&tag,
|
||||
FORMAT_VERSION_V1,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
@@ -788,10 +852,17 @@ mod tests {
|
||||
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");
|
||||
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 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);
|
||||
}
|
||||
@@ -815,9 +886,16 @@ mod tests {
|
||||
.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() };
|
||||
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");
|
||||
assert!(
|
||||
mnemonic_to_dek(&corrupted_phrase).is_err(),
|
||||
"Checksum check must fail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -860,8 +938,14 @@ mod tests {
|
||||
assert_eq!(decrypted, filename);
|
||||
|
||||
// Abwärtskompatibilität: Legacy $h$<nonce>$<tag>$<ct> Format muss weiter entschlüsselt werden
|
||||
let legacy_format = format!("$h${}${}${}", &encrypted[0..24], &encrypted[24..56], &encrypted[56..]);
|
||||
let decrypted_legacy = decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name");
|
||||
let legacy_format = format!(
|
||||
"$h${}${}${}",
|
||||
&encrypted[0..24],
|
||||
&encrypted[24..56],
|
||||
&encrypted[56..]
|
||||
);
|
||||
let decrypted_legacy =
|
||||
decrypt_node_name(&dek, parent_id, &legacy_format).expect("Decrypt legacy $h$ name");
|
||||
assert_eq!(decrypted_legacy, filename);
|
||||
|
||||
// Echte statische AAD Legacy-Verschlüsselung (b"SANCTUM_NODE_NAME")
|
||||
@@ -869,7 +953,11 @@ mod tests {
|
||||
let mut static_buf = filename.as_bytes().to_vec();
|
||||
let static_nonce = [42u8; 12];
|
||||
let static_tag = cipher
|
||||
.encrypt_in_place_detached(Nonce::from_slice(&static_nonce), b"SANCTUM_NODE_NAME", &mut static_buf)
|
||||
.encrypt_in_place_detached(
|
||||
Nonce::from_slice(&static_nonce),
|
||||
b"SANCTUM_NODE_NAME",
|
||||
&mut static_buf,
|
||||
)
|
||||
.unwrap();
|
||||
let legacy_static_format = format!(
|
||||
"$h${}${}${}",
|
||||
@@ -882,7 +970,8 @@ mod tests {
|
||||
|
||||
// Mit aktiviertem Legacy-Flag darf es entschlüsselt werden
|
||||
set_allow_legacy_names(true);
|
||||
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format).expect("Decrypt legacy static AAD name with flag");
|
||||
let decrypted_static = decrypt_node_name(&dek, parent_id, &legacy_static_format)
|
||||
.expect("Decrypt legacy static AAD name with flag");
|
||||
assert_eq!(decrypted_static, filename);
|
||||
set_allow_legacy_names(false);
|
||||
|
||||
@@ -904,17 +993,33 @@ mod tests {
|
||||
let enc_folder_a = encrypt_node_name(&dek, 10, "secrets.txt");
|
||||
let enc_folder_b = encrypt_node_name(&dek, 20, "passwords.txt");
|
||||
// Gültige parent_ids entschlüsseln erfolgreich
|
||||
assert_eq!(decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(), "secrets.txt");
|
||||
assert_eq!(decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(), "passwords.txt");
|
||||
assert_eq!(
|
||||
decrypt_node_name(&dek, 10, &enc_folder_a).unwrap(),
|
||||
"secrets.txt"
|
||||
);
|
||||
assert_eq!(
|
||||
decrypt_node_name(&dek, 20, &enc_folder_b).unwrap(),
|
||||
"passwords.txt"
|
||||
);
|
||||
|
||||
// Swap-Angriff: Ein Angreifer verschiebt enc_folder_a in Ordner 20
|
||||
assert!(decrypt_node_name(&dek, 20, &enc_folder_a).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
|
||||
assert!(decrypt_node_name(&dek, 10, &enc_folder_b).is_none(), "Swap in anderen Ordner muss durch AAD fehlschlagen!");
|
||||
assert!(
|
||||
decrypt_node_name(&dek, 20, &enc_folder_a).is_none(),
|
||||
"Swap in anderen Ordner muss durch AAD fehlschlagen!"
|
||||
);
|
||||
assert!(
|
||||
decrypt_node_name(&dek, 10, &enc_folder_b).is_none(),
|
||||
"Swap in anderen Ordner muss durch AAD fehlschlagen!"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_a_slot_payloads() {
|
||||
let test_kdf = KdfParams { memory_cost: MIN_MEMORY_COST_KIB, time_cost: MIN_TIME_COST, parallelism: 1 };
|
||||
let test_kdf = KdfParams {
|
||||
memory_cost: MIN_MEMORY_COST_KIB,
|
||||
time_cost: MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
let kek_0 = derive_kek("DecoyPass123!", &generate_salt(), &test_kdf).unwrap();
|
||||
let kek_1 = derive_kek("HiddenPass123!", &generate_salt(), &test_kdf).unwrap();
|
||||
let dek_0 = generate_dek();
|
||||
@@ -922,7 +1027,8 @@ mod tests {
|
||||
let carrier_node_id = 42i64;
|
||||
|
||||
// Slot 0 Payload: 40 Bytes
|
||||
let (wrapped_0, nonce_0, tag_0) = wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
||||
let (wrapped_0, nonce_0, tag_0) =
|
||||
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id).unwrap();
|
||||
assert_eq!(wrapped_0.len(), 40);
|
||||
|
||||
let unwrapped_0 = unwrap_key_payload(&kek_0, &wrapped_0, &nonce_0, &tag_0).unwrap();
|
||||
@@ -932,7 +1038,8 @@ mod tests {
|
||||
assert_eq!(recovered_cid_0, carrier_node_id);
|
||||
|
||||
// Slot 1 Payload: 72 Bytes
|
||||
let (wrapped_1, nonce_1, tag_1) = wrap_slot1_payload(&kek_1, &dek_1, &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();
|
||||
assert_eq!(wrapped_1.len(), 72);
|
||||
|
||||
let unwrapped_1 = unwrap_key_payload(&kek_1, &wrapped_1, &nonce_1, &tag_1).unwrap();
|
||||
@@ -964,15 +1071,30 @@ mod tests {
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let mut ct = malicious_plaintext.clone();
|
||||
let tag = cipher.encrypt_in_place_detached(nonce, &aad, &mut ct).unwrap();
|
||||
let tag = cipher
|
||||
.encrypt_in_place_detached(nonce, &aad, &mut ct)
|
||||
.unwrap();
|
||||
let tag_bytes: [u8; 16] = tag.as_slice().try_into().unwrap();
|
||||
|
||||
// Entschlüsselung muss fehlschlagen, da Dekomprimierungs-Bomb-Schutz greift
|
||||
let res = decrypt_chunk(&dek, node_id, chunk_index, &ct, &nonce_bytes, &tag_bytes, FORMAT_VERSION_V2);
|
||||
assert!(res.is_err(), "Dekomprimierungs-Bomb über 1 MB muss abgewiesen werden!");
|
||||
let res = decrypt_chunk(
|
||||
&dek,
|
||||
node_id,
|
||||
chunk_index,
|
||||
&ct,
|
||||
&nonce_bytes,
|
||||
&tag_bytes,
|
||||
FORMAT_VERSION_V2,
|
||||
);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Dekomprimierungs-Bomb über 1 MB muss abgewiesen werden!"
|
||||
);
|
||||
let err_msg = res.err().unwrap().to_string();
|
||||
assert!(err_msg.contains("Decompression-Bomb Schutz ausgelöst"), "Fehlermeldung erwartet: {}", err_msg);
|
||||
assert!(
|
||||
err_msg.contains("Decompression-Bomb Schutz ausgelöst"),
|
||||
"Fehlermeldung erwartet: {}",
|
||||
err_msg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+361
-110
@@ -295,18 +295,30 @@ fn parse_drive_letter(s: &str) -> Result<char> {
|
||||
pub fn parse_size_string(s: &str) -> Result<u64> {
|
||||
let trimmed = s.trim().to_uppercase();
|
||||
if let Some(num_str) = trimmed.strip_suffix("GB") {
|
||||
let n: u64 = num_str.trim().parse().context("Ungültige Gigabyte-Angabe")?;
|
||||
let n: u64 = num_str
|
||||
.trim()
|
||||
.parse()
|
||||
.context("Ungültige Gigabyte-Angabe")?;
|
||||
Ok(n * 1024 * 1024 * 1024)
|
||||
} else if let Some(num_str) = trimmed.strip_suffix("MB") {
|
||||
let n: u64 = num_str.trim().parse().context("Ungültige Megabyte-Angabe")?;
|
||||
let n: u64 = num_str
|
||||
.trim()
|
||||
.parse()
|
||||
.context("Ungültige Megabyte-Angabe")?;
|
||||
Ok(n * 1024 * 1024)
|
||||
} else if let Some(num_str) = trimmed.strip_suffix("KB") {
|
||||
let n: u64 = num_str.trim().parse().context("Ungültige Kilobyte-Angabe")?;
|
||||
let n: u64 = num_str
|
||||
.trim()
|
||||
.parse()
|
||||
.context("Ungültige Kilobyte-Angabe")?;
|
||||
Ok(n * 1024)
|
||||
} else if let Ok(n) = trimmed.parse::<u64>() {
|
||||
Ok(n)
|
||||
} else {
|
||||
bail!("Ungültiges Größenformat: '{}'. Erwartet z. B. '500MB', '1GB', '2GB'", s);
|
||||
bail!(
|
||||
"Ungültiges Größenformat: '{}'. Erwartet z. B. '500MB', '1GB', '2GB'",
|
||||
s
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,9 +331,14 @@ pub fn parse_size_string(s: &str) -> Result<u64> {
|
||||
fn resolve_recovery_key(arg: Option<&str>) -> Result<Option<Zeroizing<String>>> {
|
||||
if let Some(key) = arg {
|
||||
if key == "-" {
|
||||
println!(" [{}] Lese 24-Wort Notfallschlüssel von der Standardeingabe (stdin)...", ui::cyan("ℹ"));
|
||||
println!(
|
||||
" [{}] Lese 24-Wort Notfallschlüssel von der Standardeingabe (stdin)...",
|
||||
ui::cyan("ℹ")
|
||||
);
|
||||
let mut line = String::new();
|
||||
std::io::stdin().read_line(&mut line).context("Fehler beim Einlesen von stdin")?;
|
||||
std::io::stdin()
|
||||
.read_line(&mut line)
|
||||
.context("Fehler beim Einlesen von stdin")?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
bail!("Der Notfallschlüssel von stdin darf nicht leer sein.");
|
||||
@@ -342,7 +359,10 @@ fn resolve_recovery_key(arg: Option<&str>) -> Result<Option<Zeroizing<String>>>
|
||||
std::env::remove_var("SANCTUM_RECOVERY_KEY");
|
||||
let trimmed = env_key.trim();
|
||||
if !trimmed.is_empty() {
|
||||
println!(" [{}] Verwende Notfallschlüssel aus Umgebungsvariable 'SANCTUM_RECOVERY_KEY'.", ui::cyan("ℹ"));
|
||||
println!(
|
||||
" [{}] Verwende Notfallschlüssel aus Umgebungsvariable 'SANCTUM_RECOVERY_KEY'.",
|
||||
ui::cyan("ℹ")
|
||||
);
|
||||
Ok(Some(Zeroizing::new(trimmed.to_string())))
|
||||
} else {
|
||||
Ok(None)
|
||||
@@ -355,8 +375,14 @@ fn resolve_recovery_key(arg: Option<&str>) -> Result<Option<Zeroizing<String>>>
|
||||
/// Liest den 24-Wort BIP-39 Notfallschlüssel interaktiv und maskiert ein,
|
||||
/// ohne dass Eingaben in der PowerShell-Historie (ConsoleHost_history.txt) landen.
|
||||
fn prompt_recovery_key_interactive() -> Result<Zeroizing<String>> {
|
||||
println!(" [{}] Geben Sie die 24 Notfall-Wörter durch Leerzeichen getrennt ein.", ui::cyan("ℹ"));
|
||||
println!(" [{}] Die Eingabe erfolgt geschützt ohne Aufzeichnung in der Shell-Historie.", ui::dim("OpSec"));
|
||||
println!(
|
||||
" [{}] Geben Sie die 24 Notfall-Wörter durch Leerzeichen getrennt ein.",
|
||||
ui::cyan("ℹ")
|
||||
);
|
||||
println!(
|
||||
" [{}] Die Eingabe erfolgt geschützt ohne Aufzeichnung in der Shell-Historie.",
|
||||
ui::dim("OpSec")
|
||||
);
|
||||
let phrase = rpassword::prompt_password("24-Wort Notfallschlüssel: ")
|
||||
.context("Fehler beim Einlesen des Notfallschlüssels")?;
|
||||
if phrase.trim().is_empty() {
|
||||
@@ -383,8 +409,15 @@ fn handle_init(
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" Zieldatei: {}", container_path.display());
|
||||
if with_hidden {
|
||||
println!(" Modus: Dual-Vault (Modell A: {})", ui::magenta("Carrier-Datei"));
|
||||
println!(" Carrier: {} ({})", ui::cyan(carrier_name), ui::cyan(carrier_size_str));
|
||||
println!(
|
||||
" Modus: Dual-Vault (Modell A: {})",
|
||||
ui::magenta("Carrier-Datei")
|
||||
);
|
||||
println!(
|
||||
" Carrier: {} ({})",
|
||||
ui::cyan(carrier_name),
|
||||
ui::cyan(carrier_size_str)
|
||||
);
|
||||
println!(
|
||||
" [{}] Hinweis zum Dual-Vault: Modell A kapselt den Hidden Vault in einer Datei im Decoy-Vault. Dies schützt vor neugierigen Blicken, widersteht jedoch keinem forensischen Gutachten, da Carrier-Entropie und Schreibmuster analysierbar sind.",
|
||||
ui::yellow("Info")
|
||||
@@ -415,7 +448,10 @@ fn handle_init(
|
||||
|
||||
println!();
|
||||
println!(" ─── [2/2] Hidden Vault (Zweiter isolierter Tresor / Dual-Vault) ───");
|
||||
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 = Zeroizing::new(
|
||||
rpassword::prompt_password("Master-Passwort für Hidden-Vault eingeben: ")
|
||||
.context("Fehler beim Einlesen des Passworts")?,
|
||||
@@ -432,7 +468,12 @@ fn handle_init(
|
||||
}
|
||||
|
||||
println!();
|
||||
ui::step(1, 4, "🔑", "Leite KEKs für Standard- und Hidden-Vault via Argon2id ab...");
|
||||
ui::step(
|
||||
1,
|
||||
4,
|
||||
"🔑",
|
||||
"Leite KEKs für Standard- und Hidden-Vault via Argon2id ab...",
|
||||
);
|
||||
let salt_0 = generate_salt();
|
||||
let kdf_params_0 = KdfParams::default();
|
||||
let kek_0 = derive_kek(&password_0, &salt_0, &kdf_params_0)?;
|
||||
@@ -445,14 +486,24 @@ fn handle_init(
|
||||
let dek_0 = generate_dek();
|
||||
let dek_1 = generate_dek();
|
||||
|
||||
ui::step(3, 4, "🔒", "Verschlüssele Slot-Payloads (Dual-Vault Key-Wrapping)...");
|
||||
ui::step(
|
||||
3,
|
||||
4,
|
||||
"🔒",
|
||||
"Verschlüssele Slot-Payloads (Dual-Vault Key-Wrapping)...",
|
||||
);
|
||||
let carrier_node_id = 3i64;
|
||||
let (wrapped_dek_0, header_nonce_0, header_tag_0) =
|
||||
wrap_slot0_payload(&kek_0, &dek_0, carrier_node_id)?;
|
||||
|
||||
ui::step(4, 4, "📦", "Erzeuge SQLite-Container & alloziiere Alibi-Carrier-Datei...");
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
|
||||
ui::step(
|
||||
4,
|
||||
4,
|
||||
"📦",
|
||||
"Erzeuge SQLite-Container & alloziiere Alibi-Carrier-Datei...",
|
||||
);
|
||||
let db =
|
||||
Database::open(container_path).context("Konnte SQLite-Containerdatei nicht anlegen")?;
|
||||
|
||||
let (wrapped_dek_1, header_nonce_1, header_tag_1) =
|
||||
wrap_slot1_payload(&kek_1, &dek_1, &dek_0, carrier_node_id)?;
|
||||
@@ -487,7 +538,10 @@ fn handle_init(
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
println!(" • Container: {}", container_path.display());
|
||||
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
|
||||
println!(
|
||||
" • Format: Version {} (Magic: SANCTUM\\0)",
|
||||
FORMAT_VERSION
|
||||
);
|
||||
println!(" • Carrier-Datei:{} ({})", carrier_name, carrier_size_str);
|
||||
println!(" • KDF: Argon2id pro Slot (M=64MB, T=3, P=4)");
|
||||
println!(" • Kapselung: Hidden Vault liegt in Carrier-Datei im Decoy-Vault");
|
||||
@@ -495,8 +549,16 @@ fn handle_init(
|
||||
println!(" • Dateigröße: Feste Trägergröße zur Vermeidung von Größenveränderungen");
|
||||
println!();
|
||||
println!(" Befehl zum Einbinden als Netzlaufwerk:");
|
||||
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display())));
|
||||
println!(" (Die Eingabe des jeweiligen Passworts bindet automatisch den passenden Vault ein)");
|
||||
println!(
|
||||
" {}",
|
||||
ui::cyan(&format!(
|
||||
"sanctum mount --path \"{}\"",
|
||||
container_path.display()
|
||||
))
|
||||
);
|
||||
println!(
|
||||
" (Die Eingabe des jeweiligen Passworts bindet automatisch den passenden Vault ein)"
|
||||
);
|
||||
println!();
|
||||
|
||||
println!(" ┌─────────────────────────────────────────────────────────┐");
|
||||
@@ -525,25 +587,47 @@ fn handle_init(
|
||||
}
|
||||
|
||||
println!();
|
||||
ui::step(1, 4, "🔑", "Leite KEK via Argon2id ab (M=64MB, T=3, P=4)...");
|
||||
ui::step(
|
||||
1,
|
||||
4,
|
||||
"🔑",
|
||||
"Leite KEK via Argon2id ab (M=64MB, T=3, P=4)...",
|
||||
);
|
||||
let salt = generate_salt();
|
||||
let kdf_params = KdfParams::default();
|
||||
let kek = derive_kek(&password, &salt, &kdf_params)
|
||||
.context("KDF-Schlüsselableitung fehlgeschlagen")?;
|
||||
|
||||
ui::step(2, 4, "🎲", "Erzeuge kryptografisch sicheren DEK via CSPRNG...");
|
||||
ui::step(
|
||||
2,
|
||||
4,
|
||||
"🎲",
|
||||
"Erzeuge kryptografisch sicheren DEK via CSPRNG...",
|
||||
);
|
||||
let dek = generate_dek();
|
||||
|
||||
ui::step(3, 4, "🔒", "Verschlüssele DEK via AES-256-GCM...");
|
||||
let (wrapped_dek, header_nonce, header_tag) =
|
||||
wrap_slot0_payload(&kek, &dek, 0).context("DEK-Wrapping fehlgeschlagen")?;
|
||||
|
||||
ui::step(4, 4, "📦", "Initialisiere SQLite-Containerstruktur & WAL-Modus...");
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte SQLite-Containerdatei nicht anlegen")?;
|
||||
ui::step(
|
||||
4,
|
||||
4,
|
||||
"📦",
|
||||
"Initialisiere SQLite-Containerstruktur & WAL-Modus...",
|
||||
);
|
||||
let db =
|
||||
Database::open(container_path).context("Konnte SQLite-Containerdatei nicht anlegen")?;
|
||||
|
||||
db.init_schema_with_carrier(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag, None)
|
||||
.context("Fehler bei der Schema-Initialisierung")?;
|
||||
db.init_schema_with_carrier(
|
||||
&salt,
|
||||
&kdf_params,
|
||||
&wrapped_dek,
|
||||
&header_nonce,
|
||||
&header_tag,
|
||||
None,
|
||||
)
|
||||
.context("Fehler bei der Schema-Initialisierung")?;
|
||||
|
||||
db.checkpoint()
|
||||
.context("Fehler beim finalen WAL-Checkpoint")?;
|
||||
@@ -557,13 +641,22 @@ fn handle_init(
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
println!(" • Container: {}", container_path.display());
|
||||
println!(" • Format: Version {} (Magic: SANCTUM\\0)", FORMAT_VERSION);
|
||||
println!(
|
||||
" • Format: Version {} (Magic: SANCTUM\\0)",
|
||||
FORMAT_VERSION
|
||||
);
|
||||
println!(" • KDF: Argon2id (M=64MB, T=3, P=4)");
|
||||
println!(" • Cipher: AES-256-GCM + LZ4-Kompression (1-MB Chunks, AEAD)");
|
||||
println!(" • Dual-Vault: Slot 1 mit Zufallsrauschen initialisiert (inaktiv)");
|
||||
println!();
|
||||
println!(" Befehl zum Einbinden als Netzlaufwerk:");
|
||||
println!(" {}", ui::cyan(&format!("sanctum mount --path \"{}\"", container_path.display())));
|
||||
println!(
|
||||
" {}",
|
||||
ui::cyan(&format!(
|
||||
"sanctum mount --path \"{}\"",
|
||||
container_path.display()
|
||||
))
|
||||
);
|
||||
println!();
|
||||
|
||||
ui::print_recovery_phrase_card(&recovery_phrase);
|
||||
@@ -574,7 +667,10 @@ fn handle_init(
|
||||
|
||||
fn handle_compact(container_path: &Path, pages: Option<usize>) -> Result<()> {
|
||||
if !container_path.exists() {
|
||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||
bail!(
|
||||
"Containerdatei '{}' existiert nicht.",
|
||||
container_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
@@ -583,13 +679,16 @@ fn handle_compact(container_path: &Path, pages: Option<usize>) -> Result<()> {
|
||||
println!(" Container: {}", container_path.display());
|
||||
println!();
|
||||
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
|
||||
let freelist_before = db.freelist_count()
|
||||
let freelist_before = db
|
||||
.freelist_count()
|
||||
.context("Konnte Freelist-Größe nicht ermitteln")?;
|
||||
|
||||
println!(" • Freie Seiten in Freelist: {}", ui::cyan(&freelist_before.to_string()));
|
||||
println!(
|
||||
" • Freie Seiten in Freelist: {}",
|
||||
ui::cyan(&freelist_before.to_string())
|
||||
);
|
||||
|
||||
if freelist_before == 0 {
|
||||
println!(" • Der Container ist bereits optimal kompaktiert. Keine Bereinigung nötig.");
|
||||
@@ -598,7 +697,8 @@ fn handle_compact(container_path: &Path, pages: Option<usize>) -> Result<()> {
|
||||
}
|
||||
|
||||
ui::step(1, 2, "🧹", "Führe Incremental-Vacuum aus...");
|
||||
let freed = db.incremental_vacuum(pages)
|
||||
let freed = db
|
||||
.incremental_vacuum(pages)
|
||||
.context("Fehler bei der Speicherbereinigung (Incremental Vacuum)")?;
|
||||
|
||||
ui::step(2, 2, "💾", "Führe finalen WAL-Checkpoint durch...");
|
||||
@@ -612,8 +712,14 @@ fn handle_compact(container_path: &Path, pages: Option<usize>) -> Result<()> {
|
||||
println!("│ ✔ Container erfolgreich kompaktiert! │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
println!(" • Freigegebene Seiten: {}", ui::green(&freed.to_string()));
|
||||
println!(" • Verbleibende Freeseiten: {}", ui::dim(&freelist_after.to_string()));
|
||||
println!(
|
||||
" • Freigegebene Seiten: {}",
|
||||
ui::green(&freed.to_string())
|
||||
);
|
||||
println!(
|
||||
" • Verbleibende Freeseiten: {}",
|
||||
ui::dim(&freelist_after.to_string())
|
||||
);
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
@@ -632,24 +738,29 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
|
||||
println!(" Container: {}", container_path.display());
|
||||
println!();
|
||||
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
|
||||
let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(actual_phrase) = resolve_recovery_key(recovery_key)? {
|
||||
let (dek, target_slot_id, carrier_dek, carrier_node_id) = if let Some(actual_phrase) =
|
||||
resolve_recovery_key(recovery_key)?
|
||||
{
|
||||
ui::step(1, 3, "🔑", "Lese DEK aus 24-Wort Notfallschlüssel...");
|
||||
let d = mnemonic_to_dek(&actual_phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||
ui::step(2, 3, "🔓", "Notfallschlüssel verifiziert!");
|
||||
|
||||
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
|
||||
let meta = db
|
||||
.read_meta()
|
||||
.context("Konnte Container-Header nicht lesen")?;
|
||||
let detected_slot = detect_recovery_key_slot(&db, &d, meta.version).unwrap_or(0);
|
||||
if detected_slot == 1 {
|
||||
println!(" Notfallschlüssel gehört zu Slot 1 (Hidden Vault).");
|
||||
println!(" Zur Neuverpackung von Slot 1 wird das Passwort des Decoy-Tresors (Slot 0) benötigt.");
|
||||
let decoy_pw = Zeroizing::new(
|
||||
rpassword::prompt_password("Passwort für Decoy-Tresor (Slot 0): ")
|
||||
.context("Fehler beim Einlesen des Decoy-Passworts")?
|
||||
.context("Fehler beim Einlesen des Decoy-Passworts")?,
|
||||
);
|
||||
let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen."))?;
|
||||
let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| {
|
||||
anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen.")
|
||||
})?;
|
||||
let c_dek = decoy_auth.dek().clone();
|
||||
(d, 1, Some(c_dek), db.find_carrier_node_id()?)
|
||||
} else {
|
||||
@@ -671,10 +782,17 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
|
||||
.read_meta()
|
||||
.context("Konnte Container-Header nicht lesen")?;
|
||||
|
||||
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab & prüfe Passwort (konstante Zeit)...");
|
||||
let keys = meta
|
||||
.authenticate(&old_password)
|
||||
.ok_or_else(|| anyhow::anyhow!("Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."))?;
|
||||
ui::step(
|
||||
2,
|
||||
4,
|
||||
"🔑",
|
||||
"Leite KEK via Argon2id ab & prüfe Passwort (konstante Zeit)...",
|
||||
);
|
||||
let keys = meta.authenticate(&old_password).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Ungültiges aktuelles Master-Passwort! Authentifizierung fehlgeschlagen."
|
||||
)
|
||||
})?;
|
||||
let d = keys.dek().clone();
|
||||
let slot_id = keys.slot_id();
|
||||
(d, slot_id, keys.carrier_dek(), keys.carrier_node_id())
|
||||
@@ -697,14 +815,20 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
|
||||
}
|
||||
|
||||
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_params = KdfParams::default();
|
||||
let new_kek = derive_kek(&new_password, &new_salt, &new_params)
|
||||
.context("KDF-Schlüsselableitung mit neuem Passwort fehlgeschlagen")?;
|
||||
|
||||
let (new_wrapped_dek, new_nonce, new_tag) = if target_slot_id == 1 {
|
||||
let c_dek = carrier_dek.ok_or_else(|| anyhow::anyhow!("DEK_0 (Träger-Schlüssel) für Slot 1 fehlt"))?;
|
||||
let c_dek = carrier_dek
|
||||
.ok_or_else(|| anyhow::anyhow!("DEK_0 (Träger-Schlüssel) für Slot 1 fehlt"))?;
|
||||
let c_nid = carrier_node_id.unwrap_or(0);
|
||||
wrap_slot1_payload(&new_kek, &dek, &c_dek, c_nid)
|
||||
.context("Slot-1 Wrapping mit neuem KEK fehlgeschlagen")?
|
||||
@@ -714,7 +838,12 @@ fn handle_passwd(container_path: &Path, recovery_key: Option<&str>) -> Result<()
|
||||
.context("Slot-0 Wrapping mit neuem KEK fehlgeschlagen")?
|
||||
};
|
||||
|
||||
ui::step(4, 4, "💾", "Aktualisiere Container-Header & führe Checkpoint aus...");
|
||||
ui::step(
|
||||
4,
|
||||
4,
|
||||
"💾",
|
||||
"Aktualisiere Container-Header & führe Checkpoint aus...",
|
||||
);
|
||||
db.update_slot_keys(
|
||||
target_slot_id,
|
||||
&new_salt,
|
||||
@@ -774,10 +903,16 @@ fn handle_backup_header(container_path: &Path, output_path: Option<&Path>) -> Re
|
||||
|
||||
fn handle_backup(container_path: &Path, output_path: &Path) -> Result<()> {
|
||||
if !container_path.exists() {
|
||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||
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());
|
||||
bail!(
|
||||
"Zieldatei '{}' existiert bereits. Bitte wählen Sie einen anderen Pfad.",
|
||||
output_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
@@ -787,11 +922,20 @@ fn handle_backup(container_path: &Path, output_path: &Path) -> Result<()> {
|
||||
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(
|
||||
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...");
|
||||
ui::step(
|
||||
2,
|
||||
2,
|
||||
"💾",
|
||||
"Kopiere Datenbankseiten konsistent via SQLite Backup API...",
|
||||
);
|
||||
db.online_backup(output_path)
|
||||
.context("Fehler beim Erstellen des Online-Backups")?;
|
||||
|
||||
@@ -801,7 +945,9 @@ fn handle_backup(container_path: &Path, output_path: &Path) -> Result<()> {
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
println!(" • Backup-Datei: {}", output_path.display());
|
||||
println!(" • Status: Konsistent & verifiziert (kann im laufenden Betrieb gesichert werden)");
|
||||
println!(
|
||||
" • Status: Konsistent & verifiziert (kann im laufenden Betrieb gesichert werden)"
|
||||
);
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
@@ -826,7 +972,12 @@ fn handle_restore(backup_path: &Path, output_path: &Path) -> Result<()> {
|
||||
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!");
|
||||
ui::step(
|
||||
2,
|
||||
2,
|
||||
"🔍",
|
||||
"B-Tree Integritätsprüfung (PRAGMA quick_check) erfolgreich!",
|
||||
);
|
||||
|
||||
println!();
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
@@ -856,9 +1007,17 @@ fn handle_restore_header(
|
||||
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!");
|
||||
ui::step(
|
||||
2,
|
||||
2,
|
||||
"💾",
|
||||
"Header in Container-Datenbank zurückgeschrieben!",
|
||||
);
|
||||
println!();
|
||||
println!("{}", ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!"));
|
||||
println!(
|
||||
"{}",
|
||||
ui::green("✔ Header erfolgreich aus Sicherungsdatei wiederhergestellt!")
|
||||
);
|
||||
} else {
|
||||
let key_str = if let Some(key) = resolve_recovery_key(recovery_key)? {
|
||||
key
|
||||
@@ -887,7 +1046,8 @@ fn handle_restore_header(
|
||||
};
|
||||
|
||||
let dek = mnemonic_to_dek(&key_str).context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
let db =
|
||||
Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
|
||||
let effective_slot = if slot == 0 {
|
||||
detect_recovery_key_slot(&db, &dek, FORMAT_VERSION).unwrap_or(0)
|
||||
@@ -895,7 +1055,10 @@ fn handle_restore_header(
|
||||
slot
|
||||
};
|
||||
|
||||
println!(" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...", effective_slot);
|
||||
println!(
|
||||
" Verwende 24-Wort BIP-39 Notfallschlüssel (Ziel-Slot {})...",
|
||||
effective_slot
|
||||
);
|
||||
let new_password = Zeroizing::new(
|
||||
rpassword::prompt_password("Neues Master-Passwort festlegen: ")
|
||||
.context("Fehler beim Einlesen des Passworts")?,
|
||||
@@ -916,10 +1079,14 @@ fn handle_restore_header(
|
||||
println!(" Zur mathematischen Rekonstruktion wird das Passwort des Decoy-Tresors (Slot 0) benötigt.");
|
||||
let decoy_pw = Zeroizing::new(
|
||||
rpassword::prompt_password("Passwort für Decoy-Tresor (Slot 0): ")
|
||||
.context("Fehler beim Einlesen des Decoy-Passworts")?
|
||||
.context("Fehler beim Einlesen des Decoy-Passworts")?,
|
||||
);
|
||||
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
|
||||
let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen."))?;
|
||||
let meta = db
|
||||
.read_meta()
|
||||
.context("Konnte Container-Header nicht lesen")?;
|
||||
let decoy_auth = meta.authenticate(&decoy_pw).ok_or_else(|| {
|
||||
anyhow::anyhow!("Ungültiges Decoy-Passwort! Authentifizierung fehlgeschlagen.")
|
||||
})?;
|
||||
Some(**decoy_auth.dek())
|
||||
} else {
|
||||
None
|
||||
@@ -927,10 +1094,27 @@ fn handle_restore_header(
|
||||
let dek_0_opt = dek_0_holder.as_ref();
|
||||
|
||||
ui::step(1, 2, "🔑", "Dekodiere DEK & leite neuen KEK ab...");
|
||||
restore_slot_from_recovery_key(container_path, &key_str, &new_password, effective_slot, dek_0_opt)?;
|
||||
ui::step(2, 2, "💾", &format!("Header für Slot {} mit neuem Passwort neu synthetisiert!", effective_slot));
|
||||
restore_slot_from_recovery_key(
|
||||
container_path,
|
||||
&key_str,
|
||||
&new_password,
|
||||
effective_slot,
|
||||
dek_0_opt,
|
||||
)?;
|
||||
ui::step(
|
||||
2,
|
||||
2,
|
||||
"💾",
|
||||
&format!(
|
||||
"Header für Slot {} mit neuem Passwort neu synthetisiert!",
|
||||
effective_slot
|
||||
),
|
||||
);
|
||||
println!();
|
||||
println!("{}", ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!"));
|
||||
println!(
|
||||
"{}",
|
||||
ui::green("✔ Container-Header via Notfallschlüssel erfolgreich rekonstruiert!")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
@@ -939,7 +1123,10 @@ fn handle_restore_header(
|
||||
|
||||
fn handle_recovery_key(container_path: &Path) -> Result<()> {
|
||||
if !container_path.exists() {
|
||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||
bail!(
|
||||
"Containerdatei '{}' existiert nicht.",
|
||||
container_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
@@ -953,9 +1140,10 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> {
|
||||
.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 db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
let meta = db
|
||||
.read_meta()
|
||||
.context("Konnte Container-Header nicht lesen")?;
|
||||
|
||||
let keys = meta
|
||||
.authenticate(&password)
|
||||
@@ -970,7 +1158,10 @@ fn handle_recovery_key(container_path: &Path) -> Result<()> {
|
||||
|
||||
fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
|
||||
if !container_path.exists() {
|
||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||
bail!(
|
||||
"Containerdatei '{}' existiert nicht.",
|
||||
container_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
@@ -994,12 +1185,18 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> {
|
||||
Some(keys.dek().clone())
|
||||
}
|
||||
None => {
|
||||
println!(" {} Passwort falsch! Führe nur SQLite- und Strukturprüfung durch.", ui::yellow("⚠️"));
|
||||
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("ℹ"));
|
||||
println!(
|
||||
" {} Kein Passwort angegeben. Führe nur Strukturprüfung durch.",
|
||||
ui::dim("ℹ")
|
||||
);
|
||||
None
|
||||
};
|
||||
|
||||
@@ -1026,7 +1223,10 @@ fn handle_sync(
|
||||
recovery_key: Option<&str>,
|
||||
) -> Result<()> {
|
||||
if !container_path.exists() {
|
||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||
bail!(
|
||||
"Containerdatei '{}' existiert nicht.",
|
||||
container_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let auth = if let Some(phrase_str) = resolve_recovery_key(recovery_key)? {
|
||||
@@ -1042,9 +1242,10 @@ fn handle_sync(
|
||||
ContainerAuth::Password(Zeroizing::new(pass))
|
||||
};
|
||||
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?;
|
||||
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
let meta = db
|
||||
.read_meta()
|
||||
.context("Konnte Container-Header nicht lesen")?;
|
||||
|
||||
let (dek, version, vault_id) = match auth {
|
||||
ContainerAuth::Password(ref password) => {
|
||||
@@ -1054,8 +1255,7 @@ fn handle_sync(
|
||||
(keys.dek().clone(), meta.version, keys.slot_id())
|
||||
}
|
||||
ContainerAuth::RecoveryKey(ref phrase) => {
|
||||
let dek = mnemonic_to_dek(phrase)
|
||||
.context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||
let dek = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||
let v_id = detect_recovery_key_slot(&db, &dek, meta.version).unwrap_or(0);
|
||||
(dek, meta.version, v_id)
|
||||
}
|
||||
@@ -1085,34 +1285,35 @@ fn handle_sync(
|
||||
|
||||
println!();
|
||||
if dry_run {
|
||||
println!(" {} Ausführung im SIMULATIONS-MODUS (-n / --dry-run)!", ui::yellow("[!]"));
|
||||
println!(
|
||||
" {} Ausführung im SIMULATIONS-MODUS (-n / --dry-run)!",
|
||||
ui::yellow("[!]")
|
||||
);
|
||||
}
|
||||
println!(" • Richtung: {}", ui::cyan(dir_str));
|
||||
println!(" • Quelle: {}", source);
|
||||
println!(" • Ziel: {}", target);
|
||||
if delete {
|
||||
println!(" • Spiegelung: {} (Dateien im Ziel ohne Entsprechung werden gelöscht)", ui::red("--delete aktiv"));
|
||||
println!(
|
||||
" • Spiegelung: {} (Dateien im Ziel ohne Entsprechung werden gelöscht)",
|
||||
ui::red("--delete aktiv")
|
||||
);
|
||||
}
|
||||
if checksum {
|
||||
println!(" • Modus: Kryptografischer Inhaltsabgleich (--checksum aktiv)");
|
||||
}
|
||||
if !options.exclude_patterns.is_empty() {
|
||||
println!(" • Filter: {} Ausschlussmuster", options.exclude_patterns.len());
|
||||
println!(
|
||||
" • Filter: {} Ausschlussmuster",
|
||||
options.exclude_patterns.len()
|
||||
);
|
||||
}
|
||||
println!();
|
||||
println!(" {} Starte Synchronisation ...", ui::dim("[-]"));
|
||||
println!();
|
||||
}
|
||||
|
||||
let stats = sanctum::sync::run_sync(
|
||||
&db,
|
||||
vault_id,
|
||||
&dek,
|
||||
version,
|
||||
source,
|
||||
target,
|
||||
&options,
|
||||
)?;
|
||||
let stats = sanctum::sync::run_sync(&db, vault_id, &dek, version, source, target, &options)?;
|
||||
|
||||
if !dry_run {
|
||||
let _ = db.checkpoint();
|
||||
@@ -1124,27 +1325,59 @@ fn handle_sync(
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ ℹ Dry-Run Simulation erfolgreich abgeschlossen │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" • Gescannt: {} Dateien/Objekte", stats.files_scanned);
|
||||
println!(" • Zu übertragen: {} Dateien ({})", stats.files_transferred, ui::format_bytes(stats.bytes_transferred));
|
||||
println!(" • Bereits aktuell: {} Dateien (übersprungen)", stats.files_skipped);
|
||||
println!(
|
||||
" • Gescannt: {} Dateien/Objekte",
|
||||
stats.files_scanned
|
||||
);
|
||||
println!(
|
||||
" • Zu übertragen: {} Dateien ({})",
|
||||
stats.files_transferred,
|
||||
ui::format_bytes(stats.bytes_transferred)
|
||||
);
|
||||
println!(
|
||||
" • Bereits aktuell: {} Dateien (übersprungen)",
|
||||
stats.files_skipped
|
||||
);
|
||||
if delete {
|
||||
println!(" • Zu löschen: {} Dateien/Ordner", stats.files_deleted);
|
||||
println!(
|
||||
" • Zu löschen: {} Dateien/Ordner",
|
||||
stats.files_deleted
|
||||
);
|
||||
}
|
||||
println!(" • Hinweis: {} Es wurden keine Änderungen vorgenommen.", ui::yellow("✔"));
|
||||
println!(
|
||||
" • Hinweis: {} Es wurden keine Änderungen vorgenommen.",
|
||||
ui::yellow("✔")
|
||||
);
|
||||
} else {
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ ✔ Synchronisation erfolgreich abgeschlossen │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" • Gescannt: {} Dateien/Objekte", stats.files_scanned);
|
||||
println!(" • Übertragen: {} Dateien ({})", stats.files_transferred, ui::format_bytes(stats.bytes_transferred));
|
||||
println!(" • Übersprungen: {} Dateien (bereits aktuell)", stats.files_skipped);
|
||||
println!(
|
||||
" • Gescannt: {} Dateien/Objekte",
|
||||
stats.files_scanned
|
||||
);
|
||||
println!(
|
||||
" • Übertragen: {} Dateien ({})",
|
||||
stats.files_transferred,
|
||||
ui::format_bytes(stats.bytes_transferred)
|
||||
);
|
||||
println!(
|
||||
" • Übersprungen: {} Dateien (bereits aktuell)",
|
||||
stats.files_skipped
|
||||
);
|
||||
if delete {
|
||||
println!(" • Gelöscht: {} verwaiste Dateien/Ordner", stats.files_deleted);
|
||||
println!(
|
||||
" • Gelöscht: {} verwaiste Dateien/Ordner",
|
||||
stats.files_deleted
|
||||
);
|
||||
}
|
||||
let secs = stats.elapsed.as_secs_f64();
|
||||
let mb = (stats.bytes_transferred as f64) / (1024.0 * 1024.0);
|
||||
let speed = if secs > 0.05 { mb / secs } else { 0.0 };
|
||||
println!(" • Dauer: {:.2}s (Durchschnitt: {:.2} MB/s)", secs, speed);
|
||||
println!(
|
||||
" • Dauer: {:.2}s (Durchschnitt: {:.2} MB/s)",
|
||||
secs, speed
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
@@ -1247,11 +1480,19 @@ async fn run() -> Result<()> {
|
||||
Commands::Unmount { drive } => {
|
||||
let drive_char = parse_drive_letter(&drive)?;
|
||||
let drive_str = format_drive(drive_char);
|
||||
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str);
|
||||
print!(
|
||||
" {} Trenne Windows-Netzlaufwerk {} ... ",
|
||||
ui::dim("[-]"),
|
||||
drive_str
|
||||
);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
unmount_drive(drive_char)?;
|
||||
println!("{}", ui::green("OK"));
|
||||
println!("{} Laufwerk {} erfolgreich getrennt.", ui::green("✔"), drive_str);
|
||||
println!(
|
||||
"{} Laufwerk {} erfolgreich getrennt.",
|
||||
ui::green("✔"),
|
||||
drive_str
|
||||
);
|
||||
}
|
||||
Commands::Passwd { path, recovery_key } => {
|
||||
handle_passwd(&path, recovery_key.as_deref())?;
|
||||
@@ -1304,7 +1545,10 @@ async fn run() -> Result<()> {
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
sanctum::windows::register_explorer_integration()?;
|
||||
println!(" {} .sanctum-Dateien wurden erfolgreich im Windows Explorer verknüpft!", ui::green("✔"));
|
||||
println!(
|
||||
" {} .sanctum-Dateien wurden erfolgreich im Windows Explorer verknüpft!",
|
||||
ui::green("✔")
|
||||
);
|
||||
println!(" • Doppelklick: Öffnet und bindet den Container direkt als Laufwerk ein");
|
||||
println!(" • Rechtsklick: Bietet Optionen für Integritätsprüfung (FSCK) und Header-Backup");
|
||||
println!();
|
||||
@@ -1315,10 +1559,19 @@ async fn run() -> Result<()> {
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!();
|
||||
sanctum::windows::unregister_explorer_integration()?;
|
||||
println!(" {} .sanctum Dateiverknüpfungen wurden aus der Registry entfernt.", ui::green("✔"));
|
||||
println!(
|
||||
" {} .sanctum Dateiverknüpfungen wurden aus der Registry entfernt.",
|
||||
ui::green("✔")
|
||||
);
|
||||
println!();
|
||||
}
|
||||
Commands::Upgrade { check, yes, force, url, insecure_url } => {
|
||||
Commands::Upgrade {
|
||||
check,
|
||||
yes,
|
||||
force,
|
||||
url,
|
||||
insecure_url,
|
||||
} => {
|
||||
let options = sanctum::upgrade::UpgradeOptions {
|
||||
check_only: check,
|
||||
yes,
|
||||
@@ -1333,7 +1586,6 @@ async fn run() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Windows Konsole für UTF-8 (Code Page 65001) und VT ANSI Processing konfigurieren
|
||||
@@ -1353,4 +1605,3 @@ async fn main() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+134
-80
@@ -61,9 +61,7 @@ fn run_mount_command(drive_letter: char, port: u16, session_token: &str) -> Resu
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let dav_url = format!("dav://127.0.0.1:{}/", port);
|
||||
let _ = Command::new("gio")
|
||||
.args(["mount", &dav_url])
|
||||
.output();
|
||||
let _ = Command::new("gio").args(["mount", &dav_url]).output();
|
||||
let _ = (drive_letter, session_token);
|
||||
Ok(())
|
||||
}
|
||||
@@ -106,8 +104,7 @@ pub async fn mount_container(
|
||||
println!();
|
||||
ui::step(1, 4, "📦", "Öffne Container & verifiziere Header...");
|
||||
}
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
|
||||
let meta = db
|
||||
.read_meta()
|
||||
@@ -116,12 +113,22 @@ pub async fn mount_container(
|
||||
let (dek, carrier_dek, carrier_node_id, version, vault_id) = match auth {
|
||||
ContainerAuth::Password(ref password) => {
|
||||
if !stealth {
|
||||
ui::step(2, 4, "🔑", "Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...");
|
||||
ui::step(
|
||||
2,
|
||||
4,
|
||||
"🔑",
|
||||
"Leite KEK via Argon2id ab (konstante Zeit über alle Slots)...",
|
||||
);
|
||||
}
|
||||
match meta.authenticate(password) {
|
||||
Some(keys) => {
|
||||
if !stealth {
|
||||
ui::step(3, 4, "🔓", "Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!");
|
||||
ui::step(
|
||||
3,
|
||||
4,
|
||||
"🔓",
|
||||
"Master-Passwort erfolgreich verifiziert & DEK entschlüsselt!",
|
||||
);
|
||||
}
|
||||
let vault_id = keys.slot_id();
|
||||
let ver = keys.version();
|
||||
@@ -138,8 +145,7 @@ pub async fn mount_container(
|
||||
if !stealth {
|
||||
ui::step(2, 4, "🔑", "Dekodiere DEK aus 24-Wort Notfallschlüssel...");
|
||||
}
|
||||
let dek = mnemonic_to_dek(phrase)
|
||||
.context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||
let dek = mnemonic_to_dek(phrase).context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||
if !stealth {
|
||||
ui::step(3, 4, "🔓", "Notfallschlüssel erfolgreich verifiziert!");
|
||||
}
|
||||
@@ -152,10 +158,12 @@ pub async fn mount_container(
|
||||
println!(" [i] Der angegebene Notfallschlüssel gehört zum Hidden-Vault (Slot 1).");
|
||||
println!(" Für den Zugriff auf die Trägerdatei wird das Passwort des Standard-Vaults benötigt.");
|
||||
}
|
||||
let decoy_pass = rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
|
||||
.context("Fehler beim Einlesen des Standard-Vault Passworts")?;
|
||||
let decoy_keys = meta.authenticate(&decoy_pass)
|
||||
.ok_or_else(|| anyhow::anyhow!("Ungültiges Passwort für Standard-Vault."))?;
|
||||
let decoy_pass =
|
||||
rpassword::prompt_password("Master-Passwort für Standard-Vault eingeben: ")
|
||||
.context("Fehler beim Einlesen des Standard-Vault Passworts")?;
|
||||
let decoy_keys = meta.authenticate(&decoy_pass).ok_or_else(|| {
|
||||
anyhow::anyhow!("Ungültiges Passwort für Standard-Vault.")
|
||||
})?;
|
||||
(Some(decoy_keys.dek().clone()), Some(carrier_node_id), 1)
|
||||
} else {
|
||||
(None, Some(carrier_node_id), 0)
|
||||
@@ -164,7 +172,13 @@ pub async fn mount_container(
|
||||
(None, None, 0)
|
||||
};
|
||||
|
||||
(dek, carrier_dek, final_carrier_node_id, meta.version, vault_id)
|
||||
(
|
||||
dek,
|
||||
carrier_dek,
|
||||
final_carrier_node_id,
|
||||
meta.version,
|
||||
vault_id,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -340,7 +354,10 @@ pub async fn mount_container(
|
||||
println!();
|
||||
println!(" • Container: {}", container_path.display());
|
||||
#[cfg(windows)]
|
||||
println!(" • Netzlaufwerk: {} (im Windows Explorer bereit)", ui::cyan(&drive_str));
|
||||
println!(
|
||||
" • Netzlaufwerk: {} (im Windows Explorer bereit)",
|
||||
ui::cyan(&drive_str)
|
||||
);
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Some(mp) = mount_point {
|
||||
@@ -349,12 +366,22 @@ pub async fn mount_container(
|
||||
println!(" • Modus: WebDAV Userland-VFS");
|
||||
}
|
||||
}
|
||||
println!(" • WebDAV-URL: http://127.0.0.1:{}/ (lokal geschützt)", bound_port);
|
||||
println!(
|
||||
" • WebDAV-URL: http://127.0.0.1:{}/ (lokal geschützt)",
|
||||
bound_port
|
||||
);
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
println!(" • gio Befehl: gio mount dav://127.0.0.1:{}/", bound_port);
|
||||
println!(
|
||||
" • gio Befehl: gio mount dav://127.0.0.1:{}/",
|
||||
bound_port
|
||||
);
|
||||
if let Some(mp) = mount_point {
|
||||
println!(" • davfs2: mount -t davfs http://127.0.0.1:{}/ {}", bound_port, mp.display());
|
||||
println!(
|
||||
" • davfs2: mount -t davfs http://127.0.0.1:{}/ {}",
|
||||
bound_port,
|
||||
mp.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(secs) = idle_timeout {
|
||||
@@ -376,9 +403,15 @@ pub async fn mount_container(
|
||||
}
|
||||
println!();
|
||||
#[cfg(windows)]
|
||||
println!(" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.", ui::yellow("Tipp"));
|
||||
println!(
|
||||
" [{}] Drücke [Ctrl+C] oder nutze das Tray-Icon zum Beenden.",
|
||||
ui::yellow("Tipp")
|
||||
);
|
||||
#[cfg(not(windows))]
|
||||
println!(" [{}] Drücke [Ctrl+C] zum sicheren Beenden.", ui::yellow("Tipp"));
|
||||
println!(
|
||||
" [{}] Drücke [Ctrl+C] zum sicheren Beenden.",
|
||||
ui::yellow("Tipp")
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
@@ -386,11 +419,13 @@ pub async fn mount_container(
|
||||
#[cfg(windows)]
|
||||
let (console_close_tx, mut console_close_rx) = tokio::sync::mpsc::channel::<()>(1);
|
||||
#[cfg(windows)]
|
||||
let _console_guard = crate::windows::start_console_ctrl_monitor(console_close_tx, drive_letter).ok();
|
||||
let _console_guard =
|
||||
crate::windows::start_console_ctrl_monitor(console_close_tx, drive_letter).ok();
|
||||
|
||||
// Unix Signale (SIGTERM, SIGHUP)
|
||||
#[cfg(unix)]
|
||||
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
|
||||
let mut sigterm =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
|
||||
#[cfg(unix)]
|
||||
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()).ok();
|
||||
|
||||
@@ -503,7 +538,11 @@ pub async fn mount_container(
|
||||
} else {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
print!(" {} Trenne Windows-Netzlaufwerk {} ... ", ui::dim("[-]"), drive_str);
|
||||
print!(
|
||||
" {} Trenne Windows-Netzlaufwerk {} ... ",
|
||||
ui::dim("[-]"),
|
||||
drive_str
|
||||
);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
|
||||
// Automatisches Unmount
|
||||
@@ -527,7 +566,11 @@ pub async fn mount_container(
|
||||
// Storage-Kompaktierung (Incremental Vacuum), falls freie Seiten existieren
|
||||
if let Ok(freelist) = db.freelist_count() {
|
||||
if freelist > 0 {
|
||||
print!(" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ", ui::dim("[-]"), freelist);
|
||||
print!(
|
||||
" {} Führe Storage-Kompaktierung aus ({} freie Seiten) ... ",
|
||||
ui::dim("[-]"),
|
||||
freelist
|
||||
);
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
match db.incremental_vacuum(None) {
|
||||
Ok(freed) => println!("{} ({} Seiten freigegeben)", ui::green("OK"), freed),
|
||||
@@ -546,7 +589,10 @@ pub async fn mount_container(
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{} Sanctum Container wurde sicher und vollständig geschlossen.", ui::green("✔"));
|
||||
println!(
|
||||
"{} Sanctum Container wurde sicher und vollständig geschlossen.",
|
||||
ui::green("✔")
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
@@ -616,6 +662,7 @@ const HTTP_HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_
|
||||
|
||||
/// Hilfsfunktion zur Validierung von HTTP Basic Auth.
|
||||
/// Unterstützt Format `username:password` (wobei Passwort dem Session-Token entspricht).
|
||||
/// SA-06: Verwendet strikten Constant-Time-Vergleich gegen Timing-Side-Channel-Angriffe.
|
||||
pub fn check_basic_auth(auth_header: &str, expected_token: &str) -> bool {
|
||||
let auth_str = auth_header.trim();
|
||||
let encoded = if let Some(rest) = auth_str.strip_prefix("Basic ") {
|
||||
@@ -637,44 +684,36 @@ pub fn check_basic_auth(auth_header: &str, expected_token: &str) -> bool {
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
use subtle::ConstantTimeEq;
|
||||
if let Some((_user, pass)) = decoded_str.split_once(':') {
|
||||
pass == expected_token
|
||||
pass.as_bytes().ct_eq(expected_token.as_bytes()).into()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Hilfsfunktion zur Validierung des X-Sanctum-Token Headers.
|
||||
/// SA-06: Verwendet strikten Constant-Time-Vergleich gegen Timing-Side-Channel-Angriffe.
|
||||
pub fn check_token_header(headers: &hyper::HeaderMap, expected_token: &str) -> bool {
|
||||
use subtle::ConstantTimeEq;
|
||||
if let Some(val) = headers.get("X-Sanctum-Token") {
|
||||
if let Ok(val_str) = val.to_str() {
|
||||
return val_str.trim() == expected_token;
|
||||
return val_str
|
||||
.trim()
|
||||
.as_bytes()
|
||||
.ct_eq(expected_token.as_bytes())
|
||||
.into();
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Schneidet ein Pfad-Präfix /<session_token> aus der Request-URI heraus (Defense-in-Depth Fallback).
|
||||
pub fn strip_path_prefix(uri: &hyper::Uri, prefix: &str) -> Option<hyper::Uri> {
|
||||
let path = uri.path();
|
||||
if !path.starts_with(prefix) {
|
||||
return None;
|
||||
/// Prüft, ob eine URI oder deren Query-Parameter das sensible Session-Token enthält (SA-05 / CWE-598).
|
||||
pub fn uri_contains_token(uri: &hyper::Uri, session_token: &str) -> bool {
|
||||
if session_token.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let rest = &path[prefix.len()..];
|
||||
let new_path = if rest.is_empty() || !rest.starts_with('/') {
|
||||
format!("/{}", rest)
|
||||
} else {
|
||||
rest.to_string()
|
||||
};
|
||||
|
||||
let path_and_query = match uri.query() {
|
||||
Some(q) => format!("{}?{}", new_path, q),
|
||||
None => new_path,
|
||||
};
|
||||
|
||||
let mut parts = uri.clone().into_parts();
|
||||
parts.path_and_query = Some(path_and_query.parse().ok()?);
|
||||
hyper::Uri::from_parts(parts).ok()
|
||||
uri.path().contains(session_token) || uri.query().unwrap_or("").contains(session_token)
|
||||
}
|
||||
|
||||
/// Erzeugt eine standardkonforme HTTP 401 Unauthorized Antwort mit WWW-Authenticate Header.
|
||||
@@ -687,15 +726,15 @@ fn unauthorized_response() -> hyper::Response<dav_server::body::Body> {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Multi-Auth & Host-Header Sicherheits-Middleware aus.
|
||||
/// Führt die asynchrone WebDAV HTTP-Server-Schleife mit Header-basierter Authentifizierung & Host-Header Sicherheits-Middleware aus.
|
||||
pub async fn serve_webdav_loop(
|
||||
listener: TcpListener,
|
||||
dav_server: DavHandler,
|
||||
session_token: String,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) {
|
||||
let conn_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
|
||||
let token_prefix = format!("/{}", session_token);
|
||||
let conn_semaphore =
|
||||
std::sync::Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DAV_CONNECTIONS));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -724,15 +763,13 @@ pub async fn serve_webdav_loop(
|
||||
let io = TokioIo::new(stream);
|
||||
let handler = dav_server.clone();
|
||||
let expected_token = session_token.clone();
|
||||
let expected_prefix = token_prefix.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit; // Permit wird bei Verbindungsende automatisch freigegeben
|
||||
|
||||
let service = service_fn(move |mut req| {
|
||||
let service = service_fn(move |req| {
|
||||
let h = handler.clone();
|
||||
let token = expected_token.clone();
|
||||
let prefix = expected_prefix.clone();
|
||||
async move {
|
||||
// 1. RT-02: Strikte Fail-Closed Host-Header Validierung (Anti-DNS-Rebinding & Anti-Spoofing)
|
||||
let host_valid = match req.headers().get(hyper::header::HOST) {
|
||||
@@ -750,15 +787,27 @@ pub async fn serve_webdav_loop(
|
||||
);
|
||||
let res = hyper::Response::builder()
|
||||
.status(hyper::StatusCode::FORBIDDEN)
|
||||
.header(hyper::header::CONTENT_LENGTH, "0")
|
||||
.body(dav_server::body::Body::empty())
|
||||
.unwrap();
|
||||
return Ok::<_, Infallible>(res);
|
||||
}
|
||||
|
||||
// 2. R-05: Multi-Auth Middleware
|
||||
// 2. SA-05: Verhindere Session-Token in Request-URI oder Query (CWE-598).
|
||||
// Session-Tokens dürfen ausschließlich in HTTP-Headern übertragen werden.
|
||||
if uri_contains_token(req.uri(), &token) {
|
||||
warn!("Abgewiesener Zugriff: Session-Token in URI/Query übermittelt (SA-05 / CWE-598)");
|
||||
let res = hyper::Response::builder()
|
||||
.status(hyper::StatusCode::FORBIDDEN)
|
||||
.header(hyper::header::CONTENT_LENGTH, "0")
|
||||
.body(dav_server::body::Body::empty())
|
||||
.unwrap();
|
||||
return Ok::<_, Infallible>(res);
|
||||
}
|
||||
|
||||
// 3. SA-05 & SA-06: Header-basierte Authentifizierung mit Constant-Time Token-Vergleich
|
||||
// a) HTTP Basic Auth (Authorization: Basic ...)
|
||||
// b) Header X-Sanctum-Token
|
||||
// c) Pfad-Präfix Fallback (/<session_token>/...)
|
||||
let mut authenticated = false;
|
||||
|
||||
if let Some(auth_val) = req.headers().get(hyper::header::AUTHORIZATION) {
|
||||
@@ -773,15 +822,6 @@ pub async fn serve_webdav_loop(
|
||||
authenticated = true;
|
||||
}
|
||||
|
||||
if !authenticated {
|
||||
if let Some(rewritten_uri) = strip_path_prefix(req.uri(), &prefix) {
|
||||
*req.uri_mut() = rewritten_uri;
|
||||
authenticated = true;
|
||||
}
|
||||
} else if let Some(rewritten_uri) = strip_path_prefix(req.uri(), &prefix) {
|
||||
*req.uri_mut() = rewritten_uri;
|
||||
}
|
||||
|
||||
if !authenticated {
|
||||
debug!("Abgewiesener unauthentifizierter Zugriff auf: {}", req.uri().path());
|
||||
return Ok::<_, Infallible>(unauthorized_response());
|
||||
@@ -841,15 +881,24 @@ mod tests {
|
||||
use base64::Engine;
|
||||
let token = "deadbeefcafebabe0123456789abcdef";
|
||||
let creds = format!("sanctum:{}", token);
|
||||
let header_val = format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(creds));
|
||||
let header_val = format!(
|
||||
"Basic {}",
|
||||
base64::engine::general_purpose::STANDARD.encode(creds)
|
||||
);
|
||||
assert!(check_basic_auth(&header_val, token));
|
||||
|
||||
// Kleingeschriebenes basic Präfix
|
||||
let lower_header = format!("basic {}", base64::engine::general_purpose::STANDARD.encode(format!("user:{}", token)));
|
||||
let lower_header = format!(
|
||||
"basic {}",
|
||||
base64::engine::general_purpose::STANDARD.encode(format!("user:{}", token))
|
||||
);
|
||||
assert!(check_basic_auth(&lower_header, token));
|
||||
|
||||
// Falsches Token
|
||||
let wrong_token_header = format!("Basic {}", base64::engine::general_purpose::STANDARD.encode("sanctum:wrongtoken"));
|
||||
let wrong_token_header = format!(
|
||||
"Basic {}",
|
||||
base64::engine::general_purpose::STANDARD.encode("sanctum:wrongtoken")
|
||||
);
|
||||
assert!(!check_basic_auth(&wrong_token_header, token));
|
||||
|
||||
// Ungültiges Base64 oder Format
|
||||
@@ -873,22 +922,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_path_prefix() {
|
||||
fn test_uri_contains_token_rejection() {
|
||||
let token = "deadbeefcafebabe0123456789abcdef";
|
||||
let prefix = format!("/{}", token);
|
||||
|
||||
let uri: hyper::Uri = format!("http://127.0.0.1:8443/{}/Photos/vacation.jpg?sort=date", token).parse().unwrap();
|
||||
let stripped = strip_path_prefix(&uri, &prefix).expect("Should strip prefix");
|
||||
assert_eq!(stripped.path(), "/Photos/vacation.jpg");
|
||||
assert_eq!(stripped.query(), Some("sort=date"));
|
||||
// Token im Pfad
|
||||
let uri_path: hyper::Uri = format!("http://127.0.0.1:8443/{}/Photos/vacation.jpg", token)
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!(uri_contains_token(&uri_path, token));
|
||||
|
||||
let uri_root: hyper::Uri = format!("http://127.0.0.1:8443/{}", token).parse().unwrap();
|
||||
let stripped_root = strip_path_prefix(&uri_root, &prefix).expect("Should strip root");
|
||||
assert_eq!(stripped_root.path(), "/");
|
||||
// Token im Query-String
|
||||
let uri_query: hyper::Uri =
|
||||
format!("http://127.0.0.1:8443/Photos/vacation.jpg?token={}", token)
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!(uri_contains_token(&uri_query, token));
|
||||
|
||||
let uri_no_prefix: hyper::Uri = "http://127.0.0.1:8443/other/path".parse().unwrap();
|
||||
assert!(strip_path_prefix(&uri_no_prefix, &prefix).is_none());
|
||||
// Harmloser Pfad ohne Token
|
||||
let uri_clean: hyper::Uri = "http://127.0.0.1:8443/Photos/vacation.jpg".parse().unwrap();
|
||||
assert!(!uri_contains_token(&uri_clean, token));
|
||||
|
||||
// Leeres Token darf niemals matchen
|
||||
assert!(!uri_contains_token(&uri_clean, ""));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+7
-5
@@ -1,4 +1,4 @@
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
/// Validiert einen Datei- oder Verzeichnisnamen gegen Path-Traversal, Null-Bytes,
|
||||
/// unzulässige Steuerzeichen und Windows-reservierte Gerätenamen (S-08, R-03).
|
||||
@@ -7,7 +7,10 @@ pub fn validate_node_name(name: &str) -> Result<()> {
|
||||
bail!("Dateiname darf nicht leer sein.");
|
||||
}
|
||||
if name == "." || name == ".." {
|
||||
bail!("Ungültiger Dateiname (Verzeichnisreferenz verboten): '{}'", name);
|
||||
bail!(
|
||||
"Ungültiger Dateiname (Verzeichnisreferenz verboten): '{}'",
|
||||
name
|
||||
);
|
||||
}
|
||||
if name.contains('/') || name.contains('\\') || name.contains('\0') {
|
||||
bail!(
|
||||
@@ -29,9 +32,8 @@ pub fn validate_node_name(name: &str) -> Result<()> {
|
||||
};
|
||||
let base_upper = base_name.to_ascii_uppercase();
|
||||
let reserved = [
|
||||
"CON", "PRN", "AUX", "NUL",
|
||||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
|
||||
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
];
|
||||
if reserved.contains(&base_upper.as_str()) {
|
||||
bail!(
|
||||
|
||||
+114
-34
@@ -85,11 +85,31 @@ impl HeaderBackup {
|
||||
let mut slots = Vec::new();
|
||||
|
||||
if !self.slots.is_empty() {
|
||||
if self.slots.len() > 2 {
|
||||
bail!(
|
||||
"Ungültige Slot-Anzahl im Backup: {} (maximal 2 erlaubt)",
|
||||
self.slots.len()
|
||||
);
|
||||
}
|
||||
let mut seen_ids = std::collections::HashSet::new();
|
||||
for s in &self.slots {
|
||||
let salt_bytes = hex::decode(&s.kdf_salt_hex)
|
||||
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
|
||||
if s.slot_id > 1 {
|
||||
bail!("Ungültige Slot-ID {} im Backup: Es sind ausschließlich die Slot-IDs 0 und 1 erlaubt", s.slot_id);
|
||||
}
|
||||
if !seen_ids.insert(s.slot_id) {
|
||||
bail!("Doppelte Slot-ID {} im Backup entdeckt", s.slot_id);
|
||||
}
|
||||
crate::crypto::validate_kdf_params(&s.kdf_params).map_err(|e| {
|
||||
anyhow::anyhow!("KDF-Parameter in Slot {} ungültig: {e}", s.slot_id)
|
||||
})?;
|
||||
|
||||
let salt_bytes =
|
||||
hex::decode(&s.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());
|
||||
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);
|
||||
@@ -97,10 +117,24 @@ impl HeaderBackup {
|
||||
let wrapped_dek = hex::decode(&s.wrapped_dek_hex)
|
||||
.context("Ungültige Hex-Kodierung für wrapped_dek")?;
|
||||
|
||||
if s.slot_id == 0 {
|
||||
if wrapped_dek.len() != 40 && wrapped_dek.len() != 32 {
|
||||
bail!("Ungültige wrapped_dek-Länge in Slot 0: {} Bytes (erwartet: 40 oder 32)", wrapped_dek.len());
|
||||
}
|
||||
} else if s.slot_id == 1 {
|
||||
if wrapped_dek.len() != 72 && wrapped_dek.len() != 64 && wrapped_dek.len() != 32
|
||||
{
|
||||
bail!("Ungültige wrapped_dek-Länge in Slot 1: {} Bytes (erwartet: 72, 64 oder 32)", wrapped_dek.len());
|
||||
}
|
||||
}
|
||||
|
||||
let nonce_bytes = hex::decode(&s.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());
|
||||
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);
|
||||
@@ -108,7 +142,10 @@ impl HeaderBackup {
|
||||
let tag_bytes = hex::decode(&s.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());
|
||||
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);
|
||||
@@ -123,12 +160,19 @@ impl HeaderBackup {
|
||||
header_tag,
|
||||
});
|
||||
}
|
||||
|
||||
if !seen_ids.contains(&0) {
|
||||
bail!("Ungültiges Backup: Slot 0 (Standard/Decoy Vault) fehlt");
|
||||
}
|
||||
} else {
|
||||
// Fallback für alte Backups ohne slots-Array
|
||||
let salt_bytes = hex::decode(&self.kdf_salt_hex)
|
||||
.context("Ungültige Hex-Kodierung für KDF-Salt")?;
|
||||
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());
|
||||
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);
|
||||
@@ -139,7 +183,10 @@ impl HeaderBackup {
|
||||
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());
|
||||
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);
|
||||
@@ -147,7 +194,10 @@ impl HeaderBackup {
|
||||
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());
|
||||
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);
|
||||
@@ -180,19 +230,28 @@ impl HeaderBackup {
|
||||
/// 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());
|
||||
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 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()))?;
|
||||
fs::write(backup_path, json_data).with_context(|| {
|
||||
format!(
|
||||
"Konnte Backup-Datei '{}' nicht schreiben",
|
||||
backup_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -203,21 +262,25 @@ pub fn restore_header_backup(container_path: &Path, backup_path: &Path) -> Resul
|
||||
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 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")?;
|
||||
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")?;
|
||||
db.checkpoint()
|
||||
.context("Fehler beim WAL-Checkpoint nach Header-Wiederherstellung")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -256,14 +319,16 @@ pub fn restore_slot_from_recovery_key(
|
||||
dek_0: Option<&[u8; 32]>,
|
||||
) -> Result<()> {
|
||||
if !container_path.exists() {
|
||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||
bail!(
|
||||
"Containerdatei '{}' existiert nicht.",
|
||||
container_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
validate_password(new_password)?;
|
||||
|
||||
// 1. DEK aus 24-Wort-Phrase dekodieren & validieren
|
||||
let dek = mnemonic_to_dek(recovery_key)
|
||||
.context("Ungültiger 24-Wort Notfallschlüssel")?;
|
||||
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];
|
||||
@@ -273,8 +338,7 @@ pub fn restore_slot_from_recovery_key(
|
||||
let kek = derive_kek(new_password, &salt, &kdf_params)
|
||||
.context("Schlüsselableitung für neues Passwort fehlgeschlagen")?;
|
||||
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
|
||||
let carrier_node_id = db.find_carrier_node_id()?.unwrap_or(0);
|
||||
|
||||
@@ -347,7 +411,8 @@ pub fn restore_slot_from_recovery_key(
|
||||
db.restore_meta(&meta)
|
||||
.context("Fehler beim Schreiben des rekonstruierten Headers")?;
|
||||
|
||||
db.checkpoint().context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?;
|
||||
db.checkpoint()
|
||||
.context("Fehler beim WAL-Checkpoint nach Header-Rekonstruktion")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -370,8 +435,10 @@ mod tests {
|
||||
#[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()));
|
||||
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);
|
||||
@@ -392,7 +459,8 @@ mod tests {
|
||||
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.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// 1. Export
|
||||
@@ -416,8 +484,13 @@ mod tests {
|
||||
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");
|
||||
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);
|
||||
@@ -427,7 +500,8 @@ mod tests {
|
||||
#[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()));
|
||||
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);
|
||||
@@ -450,7 +524,8 @@ mod tests {
|
||||
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.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
// Header zerstören
|
||||
@@ -466,8 +541,13 @@ mod tests {
|
||||
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");
|
||||
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);
|
||||
|
||||
+352
-194
@@ -9,8 +9,8 @@ use rusqlite::{params, Connection, OptionalExtension};
|
||||
|
||||
use crate::crypto::{
|
||||
decrypt_node_name, derive_kek, encrypt_node_name, generate_dummy_slot, unwrap_key_payload,
|
||||
validate_kdf_params, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1, FORMAT_VERSION_V2,
|
||||
MAGIC_BYTES,
|
||||
validate_kdf_params, KdfParams, CHUNK_SIZE, FORMAT_VERSION, FORMAT_VERSION_V1,
|
||||
FORMAT_VERSION_V2, MAGIC_BYTES,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
@@ -52,11 +52,11 @@ pub struct SlotMeta {
|
||||
/// garantiert 100%ige Abwärtskompatibilität zu bestehendem Code (z. B. `auth.0`, `auth.2`).
|
||||
#[derive(Clone)]
|
||||
pub struct UnlockedKeys(
|
||||
pub Zeroizing<[u8; 32]>, // 0: DEK (DEK_0 bei Slot 0, DEK_1 bei Slot 1)
|
||||
pub u32, // 1: Formatversion
|
||||
pub u32, // 2: Slot-ID (0 = Decoy/Standard, 1 = Hidden Vault)
|
||||
pub Zeroizing<[u8; 32]>, // 0: DEK (DEK_0 bei Slot 0, DEK_1 bei Slot 1)
|
||||
pub u32, // 1: Formatversion
|
||||
pub u32, // 2: Slot-ID (0 = Decoy/Standard, 1 = Hidden Vault)
|
||||
pub Option<Zeroizing<[u8; 32]>>, // 3: Carrier DEK_0 (bei Slot 1 im Modell A vorhanden)
|
||||
pub Option<i64>, // 4: Carrier Node ID (Inode der Alibi-Datei in nodes)
|
||||
pub Option<i64>, // 4: Carrier Node ID (Inode der Alibi-Datei in nodes)
|
||||
);
|
||||
|
||||
impl UnlockedKeys {
|
||||
@@ -94,12 +94,39 @@ impl ContainerMeta {
|
||||
/// Führt für ausnahmslos ALLE vorhandenen Slots die KDF-Ableitung und das DEK-Unwrapping durch.
|
||||
/// Dadurch ist die Rechenzeit für Decoy und Hidden Vault bit-genau identisch (2x Argon2id).
|
||||
pub fn authenticate(&self, password: &str) -> Option<UnlockedKeys> {
|
||||
// SA-01 (HIGH): Strikte strukturelle Vorab-Validierung VOR jeglicher KDF-Berechnung (derive_kek)
|
||||
// Verhindert KDF-Amplification und DoS durch manipulierte Container-Metadaten
|
||||
if self.slots.is_empty() || self.slots.len() > 2 {
|
||||
return None;
|
||||
}
|
||||
if !self.slots.iter().any(|s| s.slot_id == 0) {
|
||||
return None;
|
||||
}
|
||||
if self.slots.iter().any(|s| s.slot_id > 1) {
|
||||
return None;
|
||||
}
|
||||
let mut seen_ids = std::collections::HashSet::new();
|
||||
for slot in &self.slots {
|
||||
if !seen_ids.insert(slot.slot_id) {
|
||||
return None;
|
||||
}
|
||||
if validate_kdf_params(&slot.kdf_params).is_err() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let mut matching = None;
|
||||
for slot in &self.slots {
|
||||
let res = derive_kek(password, &slot.kdf_salt, &slot.kdf_params)
|
||||
.ok()
|
||||
.and_then(|kek| {
|
||||
unwrap_key_payload(&kek, &slot.wrapped_dek, &slot.header_nonce, &slot.header_tag).ok()
|
||||
unwrap_key_payload(
|
||||
&kek,
|
||||
&slot.wrapped_dek,
|
||||
&slot.header_nonce,
|
||||
&slot.header_tag,
|
||||
)
|
||||
.ok()
|
||||
});
|
||||
|
||||
if let Some(payload) = res {
|
||||
@@ -109,7 +136,11 @@ impl ContainerMeta {
|
||||
let carrier_node_id = if payload.len() >= 40 {
|
||||
dek.copy_from_slice(&payload[0..32]);
|
||||
let cid = i64::from_le_bytes(payload[32..40].try_into().unwrap());
|
||||
if cid > 0 { Some(cid) } else { None }
|
||||
if cid > 0 {
|
||||
Some(cid)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else if payload.len() >= 32 {
|
||||
dek.copy_from_slice(&payload[0..32]);
|
||||
None
|
||||
@@ -135,7 +166,13 @@ impl ContainerMeta {
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
matching = Some(UnlockedKeys(dek_1, slot.version, 1, carrier_dek, carrier_node_id));
|
||||
matching = Some(UnlockedKeys(
|
||||
dek_1,
|
||||
slot.version,
|
||||
1,
|
||||
carrier_dek,
|
||||
carrier_node_id,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,6 +279,27 @@ impl Database {
|
||||
path_ref.display()
|
||||
);
|
||||
}
|
||||
|
||||
// SA-01 (HIGH): Sofortige strukturelle Validierung der Slot-Anzahl bei Database::open (Schutz vor KDF-Amplification/DoS)
|
||||
let has_meta_table: bool = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='meta' LIMIT 1;",
|
||||
[],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.optional()?
|
||||
.is_some();
|
||||
if has_meta_table {
|
||||
let slot_count: i64 = conn
|
||||
.query_row("SELECT count(*) FROM meta;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
if slot_count > 2 {
|
||||
bail!(
|
||||
"Ungültige Slot-Anzahl im Container: {} (maximal 2 erlaubt)",
|
||||
slot_count
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let db = Self {
|
||||
@@ -285,17 +343,25 @@ impl Database {
|
||||
/// Führt automatische, rückwärtskompatible Schema-Upgrades (z. B. Spalte slot_id, auto_vacuum, is_carrier) durch.
|
||||
pub fn ensure_schema_upgrades(&self) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let av: i64 = conn.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0)).unwrap_or(0);
|
||||
let av: i64 = conn
|
||||
.query_row("PRAGMA auto_vacuum;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
if av != 2 {
|
||||
// Upgrade bestehender Datenbanken auf INCREMENTAL auto_vacuum
|
||||
let _ = conn.execute_batch("PRAGMA auto_vacuum = INCREMENTAL; VACUUM;");
|
||||
}
|
||||
|
||||
// Spalte slot_id in meta (falls aus v1 migriert)
|
||||
let _ = conn.execute("ALTER TABLE meta ADD COLUMN slot_id INTEGER NOT NULL DEFAULT 0", []);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE meta ADD COLUMN slot_id INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
// Spalte is_carrier in nodes (S-03 Carrier-Schutz)
|
||||
let _ = conn.execute("ALTER TABLE nodes ADD COLUMN is_carrier INTEGER NOT NULL DEFAULT 0", []);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE nodes ADD COLUMN is_carrier INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -318,7 +384,9 @@ impl Database {
|
||||
/// Führt ein inkrementelles Auto-Vacuum aus, um freigegebene Datenbankseiten an das Betriebssystem zurückzugeben.
|
||||
pub fn incremental_vacuum(&self, pages: Option<usize>) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let before: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0)).unwrap_or(0);
|
||||
let before: i64 = conn
|
||||
.query_row("PRAGMA freelist_count;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
if before <= 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -337,7 +405,9 @@ impl Database {
|
||||
drop(rows);
|
||||
drop(stmt);
|
||||
|
||||
let after: i64 = conn.query_row("PRAGMA freelist_count;", [], |r| r.get(0)).unwrap_or(0);
|
||||
let after: i64 = conn
|
||||
.query_row("PRAGMA freelist_count;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
let actual_freed = (before - after).max(0) as usize;
|
||||
Ok(actual_freed.max(stepped))
|
||||
}
|
||||
@@ -352,9 +422,7 @@ impl Database {
|
||||
/// Sucht nach einem existierenden Carrier-Knoten (is_carrier = 1) (S-03).
|
||||
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 is_carrier = 1 LIMIT 1",
|
||||
)?;
|
||||
let mut stmt = conn.prepare("SELECT id FROM nodes WHERE is_carrier = 1 LIMIT 1")?;
|
||||
let id = stmt.query_row([], |r| r.get::<_, i64>(0)).optional()?;
|
||||
Ok(id)
|
||||
}
|
||||
@@ -380,16 +448,26 @@ impl Database {
|
||||
)
|
||||
SELECT 1 FROM sub WHERE id = ?2 LIMIT 1;",
|
||||
)?;
|
||||
let exists: Option<i64> = stmt.query_row(params![ancestor_id, node_id], |r| r.get(0)).optional()?;
|
||||
let exists: Option<i64> = stmt
|
||||
.query_row(params![ancestor_id, node_id], |r| r.get(0))
|
||||
.optional()?;
|
||||
Ok(exists.is_some())
|
||||
}
|
||||
|
||||
/// Überschreibt Chunks eines Knotens vor dem Löschen mit kryptografischem Zufallsrauschen (Chunk Shredding).
|
||||
///
|
||||
/// SA-07 / Technischer Hinweis zu sicherem Löschen:
|
||||
/// Diese Funktion führt eine *logische* Datenbereinigung durch (Überschreiben aller SQLite-Records
|
||||
/// des Knotens mit CSPRNG-Zufallsrauschen vor dem Löschen). Sie schützt zuverlässig vor
|
||||
/// logischer Wiederherstellung auf Dateisystem- und Datenbankebene.
|
||||
/// Auf modernen Solid-State-Drives (SSD, NVMe) oder Copy-on-Write-Dateisystemen (Btrfs, ZFS, APFS, ReFS)
|
||||
/// kann hierdurch jedoch konstruktionsbedingt keine *physikalische* Datenträgerbereinigung (Media Sanitization)
|
||||
/// garantiert werden, da der Flash Translation Layer (FTL) und Wear-Leveling-Mechanismen Sektoren
|
||||
/// neuen Flash-Speicherzellen zuweisen.
|
||||
pub fn shred_chunks_for_node(&self, node_id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1"
|
||||
)?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1")?;
|
||||
let chunks: Vec<(u32, usize)> = stmt
|
||||
.query_map(params![node_id], |row| Ok((row.get(0)?, row.get(1)?)))?
|
||||
.filter_map(|r| r.ok())
|
||||
@@ -426,15 +504,15 @@ impl Database {
|
||||
header_nonce_0: &[u8; 12],
|
||||
header_tag_0: &[u8; 16],
|
||||
carrier_config: Option<(
|
||||
&str, // carrier_name
|
||||
u64, // carrier_size_bytes
|
||||
&[u8; 16], // salt_1
|
||||
&KdfParams, // kdf_params_1
|
||||
&[u8], // wrapped_dek_1 (72B)
|
||||
&[u8; 12], // header_nonce_1
|
||||
&[u8; 16], // header_tag_1
|
||||
&[u8; 32], // raw DEK_0
|
||||
&[u8; 32], // raw DEK_1
|
||||
&str, // carrier_name
|
||||
u64, // carrier_size_bytes
|
||||
&[u8; 16], // salt_1
|
||||
&KdfParams, // kdf_params_1
|
||||
&[u8], // wrapped_dek_1 (72B)
|
||||
&[u8; 12], // header_nonce_1
|
||||
&[u8; 16], // header_tag_1
|
||||
&[u8; 32], // raw DEK_0
|
||||
&[u8; 32], // raw DEK_1
|
||||
)>,
|
||||
) -> Result<Option<i64>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
@@ -547,13 +625,8 @@ impl Database {
|
||||
let manifest = crate::carrier::CarrierManifest::new(total_blocks);
|
||||
let manifest_bytes = serde_json::to_vec(&manifest)?;
|
||||
|
||||
let (inner_ct, inner_nonce, inner_tag) = crate::crypto::encrypt_chunk(
|
||||
dek_1,
|
||||
c_id,
|
||||
0,
|
||||
&manifest_bytes,
|
||||
FORMAT_VERSION,
|
||||
)?;
|
||||
let (inner_ct, inner_nonce, inner_tag) =
|
||||
crate::crypto::encrypt_chunk(dek_1, c_id, 0, &manifest_bytes, FORMAT_VERSION)?;
|
||||
let inner_ct_len = inner_ct.len() as u32;
|
||||
|
||||
let mut outer_plaintext = vec![0u8; CHUNK_SIZE];
|
||||
@@ -568,13 +641,8 @@ impl Database {
|
||||
}
|
||||
outer_plaintext[32..ct_end].copy_from_slice(&inner_ct);
|
||||
|
||||
let (outer_ct, outer_nonce, outer_tag) = crate::crypto::encrypt_chunk(
|
||||
dek_0,
|
||||
c_id,
|
||||
0,
|
||||
&outer_plaintext,
|
||||
FORMAT_VERSION,
|
||||
)?;
|
||||
let (outer_ct, outer_nonce, outer_tag) =
|
||||
crate::crypto::encrypt_chunk(dek_0, c_id, 0, &outer_plaintext, FORMAT_VERSION)?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO chunks (node_id, chunk_index, nonce, tag, ciphertext)
|
||||
@@ -593,20 +661,9 @@ impl Database {
|
||||
|
||||
conn.execute_batch("BEGIN TRANSACTION;")?;
|
||||
for b in 1..total_blocks {
|
||||
let (ct, nonce, tag) = crate::crypto::encrypt_chunk(
|
||||
dek_0,
|
||||
c_id,
|
||||
b,
|
||||
&dummy_noise,
|
||||
FORMAT_VERSION,
|
||||
)?;
|
||||
chunk_stmt.execute(params![
|
||||
c_id,
|
||||
b,
|
||||
nonce.as_slice(),
|
||||
tag.as_slice(),
|
||||
ct,
|
||||
])?;
|
||||
let (ct, nonce, tag) =
|
||||
crate::crypto::encrypt_chunk(dek_0, c_id, b, &dummy_noise, FORMAT_VERSION)?;
|
||||
chunk_stmt.execute(params![c_id, b, nonce.as_slice(), tag.as_slice(), ct,])?;
|
||||
|
||||
if b % 500 == 0 {
|
||||
conn.execute_batch("COMMIT; BEGIN TRANSACTION;")?;
|
||||
@@ -767,18 +824,43 @@ impl Database {
|
||||
header_nonce: &[u8; 12],
|
||||
header_tag: &[u8; 16],
|
||||
) -> Result<()> {
|
||||
self.init_schema_with_hidden(salt, kdf_params, wrapped_dek, header_nonce, header_tag, None)
|
||||
self.init_schema_with_hidden(
|
||||
salt,
|
||||
kdf_params,
|
||||
wrapped_dek,
|
||||
header_nonce,
|
||||
header_tag,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Liest alle Header-Slots aus der `meta`-Tabelle aus (Slot 0 = Decoy/Standard, Slot 1 = Hidden Vault oder Dummy-Rauschen).
|
||||
/// SA-01: Führt strikte Vorab-Validierung der Container-Struktur VOR jeglicher KDF-Berechnung durch.
|
||||
pub fn read_slots(&self) -> Result<Vec<SlotMeta>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
// SA-01 (HIGH): Vorab-Prüfung der Gesamtzahl der Slots (Schutz gegen KDF-Amplification / Container DoS)
|
||||
let slot_count: i64 = conn.query_row("SELECT count(*) FROM meta", [], |r| r.get(0))?;
|
||||
if slot_count == 0 {
|
||||
bail!("Container-Header ist leer oder beschädigt");
|
||||
}
|
||||
if slot_count > 2 {
|
||||
bail!(
|
||||
"Ungültige Slot-Anzahl im Container: {} (maximal 2 erlaubt)",
|
||||
slot_count
|
||||
);
|
||||
}
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT slot_id, version, kdf_salt, kdf_params, wrapped_dek, header_nonce, header_tag
|
||||
FROM meta ORDER BY slot_id ASC",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let mut rows = stmt.query([])?;
|
||||
let mut slots = Vec::new();
|
||||
let mut seen_slot_ids = std::collections::HashSet::new();
|
||||
|
||||
while let Some(row) = rows.next()? {
|
||||
let slot_id: u32 = row.get(0)?;
|
||||
let version: u32 = row.get(1)?;
|
||||
let salt_vec: Vec<u8> = row.get(2)?;
|
||||
@@ -787,91 +869,69 @@ impl Database {
|
||||
let nonce_vec: Vec<u8> = row.get(5)?;
|
||||
let tag_vec: Vec<u8> = row.get(6)?;
|
||||
|
||||
let mut kdf_salt = [0u8; 16];
|
||||
if salt_vec.len() != 16 {
|
||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||
2,
|
||||
rusqlite::types::Type::Blob,
|
||||
Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("Ungültige Salt-Länge in Slot {}: {} Bytes (erwartet: 16)", slot_id, salt_vec.len()),
|
||||
)),
|
||||
));
|
||||
// SA-01: Ausschließlich die Slot-IDs 0 und 1 sind zulässig
|
||||
if slot_id > 1 {
|
||||
bail!(
|
||||
"Ungültige Slot-ID {}: Es sind ausschließlich die Slot-IDs 0 und 1 erlaubt",
|
||||
slot_id
|
||||
);
|
||||
}
|
||||
if !seen_slot_ids.insert(slot_id) {
|
||||
bail!("Doppelte Slot-ID {} im Container-Header entdeckt", slot_id);
|
||||
}
|
||||
|
||||
if salt_vec.len() != 16 {
|
||||
bail!(
|
||||
"Ungültige Salt-Länge in Slot {}: {} Bytes (erwartet: 16)",
|
||||
slot_id,
|
||||
salt_vec.len()
|
||||
);
|
||||
}
|
||||
let mut kdf_salt = [0u8; 16];
|
||||
kdf_salt.copy_from_slice(&salt_vec);
|
||||
|
||||
let mut header_nonce = [0u8; 12];
|
||||
if nonce_vec.len() != 12 {
|
||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||
5,
|
||||
rusqlite::types::Type::Blob,
|
||||
Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("Ungültige Nonce-Länge in Slot {}: {} Bytes (erwartet: 12)", slot_id, nonce_vec.len()),
|
||||
)),
|
||||
));
|
||||
bail!(
|
||||
"Ungültige Nonce-Länge in Slot {}: {} Bytes (erwartet: 12)",
|
||||
slot_id,
|
||||
nonce_vec.len()
|
||||
);
|
||||
}
|
||||
let mut header_nonce = [0u8; 12];
|
||||
header_nonce.copy_from_slice(&nonce_vec);
|
||||
|
||||
let mut header_tag = [0u8; 16];
|
||||
if tag_vec.len() != 16 {
|
||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||
6,
|
||||
rusqlite::types::Type::Blob,
|
||||
Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("Ungültige Tag-Länge in Slot {}: {} Bytes (erwartet: 16)", slot_id, tag_vec.len()),
|
||||
)),
|
||||
));
|
||||
bail!(
|
||||
"Ungültige Tag-Länge in Slot {}: {} Bytes (erwartet: 16)",
|
||||
slot_id,
|
||||
tag_vec.len()
|
||||
);
|
||||
}
|
||||
let mut header_tag = [0u8; 16];
|
||||
header_tag.copy_from_slice(&tag_vec);
|
||||
|
||||
// Strikte Validierung der wrapped_dek Länge (DoS- und Manipulationsschutz)
|
||||
if slot_id == 0 {
|
||||
if wrapped_dek.len() != 40 && wrapped_dek.len() != 32 {
|
||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||
4,
|
||||
rusqlite::types::Type::Blob,
|
||||
Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("Ungültige wrapped_dek-Länge in Slot 0: {} Bytes (erwartet: 40 oder 32)", wrapped_dek.len()),
|
||||
)),
|
||||
));
|
||||
bail!(
|
||||
"Ungültige wrapped_dek-Länge in Slot 0: {} Bytes (erwartet: 40 oder 32)",
|
||||
wrapped_dek.len()
|
||||
);
|
||||
}
|
||||
} else if slot_id == 1 {
|
||||
if wrapped_dek.len() != 72 && wrapped_dek.len() != 64 && wrapped_dek.len() != 32 {
|
||||
return Err(rusqlite::Error::FromSqlConversionFailure(
|
||||
4,
|
||||
rusqlite::types::Type::Blob,
|
||||
Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("Ungültige wrapped_dek-Länge in Slot 1: {} Bytes (erwartet: 72, 64 oder 32)", wrapped_dek.len()),
|
||||
)),
|
||||
));
|
||||
bail!("Ungültige wrapped_dek-Länge in Slot 1: {} Bytes (erwartet: 72, 64 oder 32)", wrapped_dek.len());
|
||||
}
|
||||
}
|
||||
|
||||
let kdf_params: KdfParams = serde_json::from_str(¶ms_str)
|
||||
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||
3,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("Ungültiges KdfParams-JSON in Slot {}: {e}", slot_id),
|
||||
)),
|
||||
))?;
|
||||
let kdf_params: KdfParams = serde_json::from_str(¶ms_str).map_err(|e| {
|
||||
anyhow::anyhow!("Ungültiges KdfParams-JSON in Slot {}: {e}", slot_id)
|
||||
})?;
|
||||
|
||||
validate_kdf_params(&kdf_params)
|
||||
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||
3,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("KDF-Parameter in Slot {} ungültig: {e}", slot_id),
|
||||
)),
|
||||
))?;
|
||||
.map_err(|e| anyhow::anyhow!("KDF-Parameter in Slot {} ungültig: {e}", slot_id))?;
|
||||
|
||||
Ok(SlotMeta {
|
||||
slots.push(SlotMeta {
|
||||
slot_id,
|
||||
version,
|
||||
kdf_salt,
|
||||
@@ -879,13 +939,14 @@ impl Database {
|
||||
wrapped_dek,
|
||||
header_nonce,
|
||||
header_tag,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut slots = Vec::new();
|
||||
for r in rows {
|
||||
slots.push(r?);
|
||||
});
|
||||
}
|
||||
|
||||
// SA-01: Slot 0 (Standard/Decoy Vault) ist zwingend erforderlich
|
||||
if !seen_slot_ids.contains(&0) {
|
||||
bail!("Ungültiger Container-Header: Slot 0 (Standard/Decoy Vault) fehlt");
|
||||
}
|
||||
|
||||
Ok(slots)
|
||||
}
|
||||
|
||||
@@ -909,7 +970,10 @@ impl Database {
|
||||
}
|
||||
|
||||
if slot0.version != FORMAT_VERSION_V1 && slot0.version != FORMAT_VERSION_V2 {
|
||||
bail!("Nicht unterstützte Sanctum-Formatversion: {}", slot0.version);
|
||||
bail!(
|
||||
"Nicht unterstützte Sanctum-Formatversion: {}",
|
||||
slot0.version
|
||||
);
|
||||
}
|
||||
|
||||
Ok(ContainerMeta {
|
||||
@@ -932,7 +996,14 @@ impl Database {
|
||||
new_header_nonce: &[u8; 12],
|
||||
new_header_tag: &[u8; 16],
|
||||
) -> Result<()> {
|
||||
self.update_slot_keys(0, new_salt, new_params, new_wrapped_dek, new_header_nonce, new_header_tag)
|
||||
self.update_slot_keys(
|
||||
0,
|
||||
new_salt,
|
||||
new_params,
|
||||
new_wrapped_dek,
|
||||
new_header_nonce,
|
||||
new_header_tag,
|
||||
)
|
||||
}
|
||||
|
||||
/// Aktualisiert die kryptografischen Schlüssel eines bestimmten Slots.
|
||||
@@ -960,7 +1031,10 @@ impl Database {
|
||||
)?;
|
||||
|
||||
if rows_affected == 0 {
|
||||
bail!("Konnte Container-Header für Slot {} nicht aktualisieren", slot_id);
|
||||
bail!(
|
||||
"Konnte Container-Header für Slot {} nicht aktualisieren",
|
||||
slot_id
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1059,7 +1133,8 @@ impl Database {
|
||||
let mut matched_record = None;
|
||||
for r in rows {
|
||||
let (id, p_id, enc_name, is_dir, size, c_at, m_at) = r?;
|
||||
let dec_name = decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name);
|
||||
let dec_name =
|
||||
decrypt_node_name(dek, p_id.unwrap_or(0), &enc_name).unwrap_or(enc_name);
|
||||
if dec_name == *segment {
|
||||
matched_record = Some(NodeRecord {
|
||||
id,
|
||||
@@ -1471,11 +1546,10 @@ impl Database {
|
||||
/// Erzwingt einen SQLite WAL Checkpoint und leert das Write-Ahead-Log.
|
||||
pub fn checkpoint(&self) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let _res: (i64, i64, i64) = conn.query_row(
|
||||
"PRAGMA wal_checkpoint(TRUNCATE);",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)?;
|
||||
let _res: (i64, i64, i64) =
|
||||
conn.query_row("PRAGMA wal_checkpoint(TRUNCATE);", [], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1650,10 +1724,9 @@ impl Database {
|
||||
/// 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 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)
|
||||
@@ -1717,7 +1790,8 @@ mod tests {
|
||||
let nonce = [3u8; 12];
|
||||
let tag = [4u8; 16];
|
||||
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
|
||||
// Meta abrufen
|
||||
let meta = db.read_meta().unwrap();
|
||||
@@ -1740,19 +1814,29 @@ mod tests {
|
||||
assert!(!file.is_dir);
|
||||
|
||||
// Pfadauflösung testen
|
||||
let resolved_file = db.resolve_path("/documents/notes.txt").unwrap().expect("File should resolve");
|
||||
let resolved_file = db
|
||||
.resolve_path("/documents/notes.txt")
|
||||
.unwrap()
|
||||
.expect("File should resolve");
|
||||
assert_eq!(resolved_file.id, file.id);
|
||||
|
||||
let resolved_docs = db.resolve_path("documents").unwrap().expect("Docs should resolve");
|
||||
let resolved_docs = db
|
||||
.resolve_path("documents")
|
||||
.unwrap()
|
||||
.expect("Docs should resolve");
|
||||
assert_eq!(resolved_docs.id, docs.id);
|
||||
|
||||
// Chunks schreiben & lesen
|
||||
let test_cipher = b"ENCRYPTED_DATA_BLOCK";
|
||||
let c_nonce = [7u8; 12];
|
||||
let c_tag = [8u8; 16];
|
||||
db.write_chunk(file.id, 0, &c_nonce, &c_tag, test_cipher).unwrap();
|
||||
db.write_chunk(file.id, 0, &c_nonce, &c_tag, test_cipher)
|
||||
.unwrap();
|
||||
|
||||
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk 0 should exist");
|
||||
let chunk = db
|
||||
.read_chunk(file.id, 0)
|
||||
.unwrap()
|
||||
.expect("Chunk 0 should exist");
|
||||
assert_eq!(chunk.ciphertext, test_cipher);
|
||||
|
||||
// Truncate
|
||||
@@ -1782,7 +1866,8 @@ mod tests {
|
||||
let nonce = [3u8; 12];
|
||||
let tag = [4u8; 16];
|
||||
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
|
||||
let root = db.resolve_path("/").unwrap().expect("Root node");
|
||||
let file = db.create_node(root.id, "large_file.bin", false).unwrap();
|
||||
@@ -1792,7 +1877,8 @@ mod tests {
|
||||
let c_nonce = [5u8; 12];
|
||||
let c_tag = [6u8; 16];
|
||||
for i in 0..20 {
|
||||
db.write_chunk(file.id, i, &c_nonce, &c_tag, &payload).unwrap();
|
||||
db.write_chunk(file.id, i, &c_nonce, &c_tag, &payload)
|
||||
.unwrap();
|
||||
}
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
@@ -1801,12 +1887,18 @@ mod tests {
|
||||
db.checkpoint().unwrap();
|
||||
|
||||
let freelist_before = db.freelist_count().unwrap();
|
||||
assert!(freelist_before > 0, "Freelist sollte nach dem Löschen freie Seiten enthalten");
|
||||
assert!(
|
||||
freelist_before > 0,
|
||||
"Freelist sollte nach dem Löschen freie Seiten enthalten"
|
||||
);
|
||||
|
||||
// Incremental Vacuum ausführen
|
||||
let freed = db.incremental_vacuum(None).unwrap();
|
||||
assert!(freed > 0, "Es sollten Seiten freigegeben werden");
|
||||
assert_eq!(freed, freelist_before, "Alle freien Seiten müssen freigegeben werden");
|
||||
assert_eq!(
|
||||
freed, freelist_before,
|
||||
"Alle freien Seiten müssen freigegeben werden"
|
||||
);
|
||||
|
||||
let freelist_after = db.freelist_count().unwrap();
|
||||
assert_eq!(freelist_after, 0, "Freelist sollte nach Vacuum 0 sein");
|
||||
@@ -1823,22 +1915,34 @@ mod tests {
|
||||
let nonce = [3u8; 12];
|
||||
let tag = [4u8; 16];
|
||||
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
let root = db.resolve_path("/").unwrap().expect("Root");
|
||||
let file = db.create_node(root.id, "sensitive.dat", false).unwrap();
|
||||
|
||||
let sensitive_payload = b"VERY_SENSITIVE_PLAINTEXT_OR_CIPHERTEXT";
|
||||
let c_nonce = [10u8; 12];
|
||||
let c_tag = [11u8; 16];
|
||||
db.write_chunk(file.id, 0, &c_nonce, &c_tag, sensitive_payload).unwrap();
|
||||
db.write_chunk(file.id, 0, &c_nonce, &c_tag, sensitive_payload)
|
||||
.unwrap();
|
||||
|
||||
// Shredde Chunks
|
||||
db.shred_chunks_for_node(file.id).unwrap();
|
||||
|
||||
// Prüfe, was sich in der Chunks-Tabelle befindet
|
||||
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk existiert noch");
|
||||
assert_ne!(chunk.ciphertext, sensitive_payload, "Ciphertext muss überschrieben sein!");
|
||||
assert_eq!(chunk.ciphertext.len(), sensitive_payload.len(), "Länge muss identisch sein");
|
||||
let chunk = db
|
||||
.read_chunk(file.id, 0)
|
||||
.unwrap()
|
||||
.expect("Chunk existiert noch");
|
||||
assert_ne!(
|
||||
chunk.ciphertext, sensitive_payload,
|
||||
"Ciphertext muss überschrieben sein!"
|
||||
);
|
||||
assert_eq!(
|
||||
chunk.ciphertext.len(),
|
||||
sensitive_payload.len(),
|
||||
"Länge muss identisch sein"
|
||||
);
|
||||
assert_ne!(chunk.nonce, c_nonce, "Nonce muss überschrieben sein");
|
||||
assert_ne!(chunk.tag, c_tag, "Tag muss überschrieben sein");
|
||||
}
|
||||
@@ -1862,9 +1966,14 @@ mod tests {
|
||||
let dek1 = [0xBBu8; 32];
|
||||
|
||||
db.init_schema_with_hidden(
|
||||
&salt0, &kdf_params0, &wrapped_dek0, &nonce0, &tag0,
|
||||
&salt0,
|
||||
&kdf_params0,
|
||||
&wrapped_dek0,
|
||||
&nonce0,
|
||||
&tag0,
|
||||
Some((&salt1, &kdf_params1, &wrapped_dek1, &nonce1, &tag1)),
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Slots prüfen
|
||||
let slots = db.read_slots().unwrap();
|
||||
@@ -1873,63 +1982,107 @@ mod tests {
|
||||
assert_eq!(slots[1].slot_id, 1);
|
||||
|
||||
// Datei in Vault 0 (Decoy) erstellen
|
||||
let root0 = db.resolve_path_in_vault("/", 0, &dek0).unwrap().expect("Root 0");
|
||||
let root0 = db
|
||||
.resolve_path_in_vault("/", 0, &dek0)
|
||||
.unwrap()
|
||||
.expect("Root 0");
|
||||
assert_eq!(root0.id, 1);
|
||||
let decoy_file = db.create_node_in_vault(0, root0.id, "public_recipe.txt", false, &dek0).unwrap();
|
||||
let decoy_file = db
|
||||
.create_node_in_vault(0, root0.id, "public_recipe.txt", false, &dek0)
|
||||
.unwrap();
|
||||
|
||||
// Datei in Vault 1 (Hidden) erstellen
|
||||
let root1 = db.resolve_path_in_vault("/", 1, &dek1).unwrap().expect("Root 1");
|
||||
let root1 = db
|
||||
.resolve_path_in_vault("/", 1, &dek1)
|
||||
.unwrap()
|
||||
.expect("Root 1");
|
||||
assert_eq!(root1.id, 2);
|
||||
let hidden_file = db.create_node_in_vault(1, root1.id, "classified_leak.pdf", false, &dek1).unwrap();
|
||||
let hidden_file = db
|
||||
.create_node_in_vault(1, root1.id, "classified_leak.pdf", false, &dek1)
|
||||
.unwrap();
|
||||
|
||||
// Auflösen in Vault 0: Sieht nur public_recipe.txt
|
||||
let res_decoy = db.resolve_path_in_vault("/public_recipe.txt", 0, &dek0).unwrap();
|
||||
let res_decoy = db
|
||||
.resolve_path_in_vault("/public_recipe.txt", 0, &dek0)
|
||||
.unwrap();
|
||||
assert!(res_decoy.is_some());
|
||||
assert_eq!(res_decoy.unwrap().id, decoy_file.id);
|
||||
|
||||
let res_hidden_in_v0 = db.resolve_path_in_vault("/classified_leak.pdf", 0, &dek0).unwrap();
|
||||
assert!(res_hidden_in_v0.is_none(), "Vault 0 darf keine Dateien aus Hidden Vault auflösen!");
|
||||
let res_hidden_in_v0 = db
|
||||
.resolve_path_in_vault("/classified_leak.pdf", 0, &dek0)
|
||||
.unwrap();
|
||||
assert!(
|
||||
res_hidden_in_v0.is_none(),
|
||||
"Vault 0 darf keine Dateien aus Hidden Vault auflösen!"
|
||||
);
|
||||
|
||||
// Auflösen in Vault 1: Sieht nur classified_leak.pdf
|
||||
let res_hidden = db.resolve_path_in_vault("/classified_leak.pdf", 1, &dek1).unwrap();
|
||||
let res_hidden = db
|
||||
.resolve_path_in_vault("/classified_leak.pdf", 1, &dek1)
|
||||
.unwrap();
|
||||
assert!(res_hidden.is_some());
|
||||
assert_eq!(res_hidden.unwrap().id, hidden_file.id);
|
||||
|
||||
let res_decoy_in_v1 = db.resolve_path_in_vault("/public_recipe.txt", 1, &dek1).unwrap();
|
||||
assert!(res_decoy_in_v1.is_none(), "Vault 1 darf keine Dateien aus Vault 0 auflösen!");
|
||||
let res_decoy_in_v1 = db
|
||||
.resolve_path_in_vault("/public_recipe.txt", 1, &dek1)
|
||||
.unwrap();
|
||||
assert!(
|
||||
res_decoy_in_v1.is_none(),
|
||||
"Vault 1 darf keine Dateien aus Vault 0 auflösen!"
|
||||
);
|
||||
|
||||
// Forensische Prüfung: Roh-Inspektion der SQLite-Tabellen
|
||||
let conn = db.conn.lock().unwrap();
|
||||
let raw_name_v0: String = conn.query_row(
|
||||
"SELECT name FROM nodes WHERE id = ?1",
|
||||
params![decoy_file.id],
|
||||
|r| r.get(0),
|
||||
).unwrap();
|
||||
let raw_name_v0: String = conn
|
||||
.query_row(
|
||||
"SELECT name FROM nodes WHERE id = ?1",
|
||||
params![decoy_file.id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(raw_name_v0, "public_recipe.txt");
|
||||
|
||||
let raw_name_v1: String = conn.query_row(
|
||||
"SELECT name FROM nodes WHERE id = ?1",
|
||||
params![hidden_file.id],
|
||||
|r| r.get(0),
|
||||
).unwrap();
|
||||
let raw_name_v1: String = conn
|
||||
.query_row(
|
||||
"SELECT name FROM nodes WHERE id = ?1",
|
||||
params![hidden_file.id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
// Dual-Vault Isolation: Kein $h$-Präfix, kein Klartext
|
||||
assert!(!raw_name_v1.starts_with("$h$"), "Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen");
|
||||
assert!(!raw_name_v1.contains("classified_leak"), "Plaintext darf keinesfalls in SQLite DB auftauchen");
|
||||
assert!(
|
||||
!raw_name_v1.starts_with("$h$"),
|
||||
"Hidden Vault Dateiname darf kein $h$-Präfix mehr besitzen"
|
||||
);
|
||||
assert!(
|
||||
!raw_name_v1.contains("classified_leak"),
|
||||
"Plaintext darf keinesfalls in SQLite DB auftauchen"
|
||||
);
|
||||
|
||||
// Keine Spalte `vault_id` in nodes oder chunks
|
||||
let has_vault_id_nodes: i64 = conn.query_row(
|
||||
"SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(has_vault_id_nodes, 0, "vault_id darf nicht in nodes existieren");
|
||||
let has_vault_id_nodes: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM pragma_table_info('nodes') WHERE name = 'vault_id'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
has_vault_id_nodes, 0,
|
||||
"vault_id darf nicht in nodes existieren"
|
||||
);
|
||||
|
||||
let has_vault_id_chunks: i64 = conn.query_row(
|
||||
"SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(has_vault_id_chunks, 0, "vault_id darf nicht in chunks existieren");
|
||||
let has_vault_id_chunks: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM pragma_table_info('chunks') WHERE name = 'vault_id'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
has_vault_id_chunks, 0,
|
||||
"vault_id darf nicht in chunks existieren"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1942,7 +2095,8 @@ mod tests {
|
||||
[3u8; 12],
|
||||
[4u8; 16],
|
||||
);
|
||||
db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag).unwrap();
|
||||
db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
|
||||
let file = db.create_node(1, "crash_test.bin", false).unwrap();
|
||||
assert_eq!(file.size, 0);
|
||||
@@ -1961,10 +2115,14 @@ mod tests {
|
||||
&ciphertext,
|
||||
new_size,
|
||||
modified_at,
|
||||
).expect("Atomic write");
|
||||
)
|
||||
.expect("Atomic write");
|
||||
|
||||
// Chunk verifizieren
|
||||
let chunk = db.read_chunk(file.id, 0).unwrap().expect("Chunk must exist");
|
||||
let chunk = db
|
||||
.read_chunk(file.id, 0)
|
||||
.unwrap()
|
||||
.expect("Chunk must exist");
|
||||
assert_eq!(chunk.ciphertext, ciphertext);
|
||||
assert_eq!(chunk.nonce, chunk_nonce);
|
||||
assert_eq!(chunk.tag, chunk_tag);
|
||||
|
||||
+159
-36
@@ -141,7 +141,8 @@ pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool {
|
||||
// Wildcard-Muster: prefix*
|
||||
else if p.ends_with('*') && !p[..p.len() - 1].contains('*') {
|
||||
let prefix = p[..p.len() - 1].to_ascii_lowercase();
|
||||
if norm_name.starts_with(&prefix) || norm_rel.to_ascii_lowercase().starts_with(&prefix) {
|
||||
if norm_name.starts_with(&prefix) || norm_rel.to_ascii_lowercase().starts_with(&prefix)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -151,7 +152,9 @@ pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool {
|
||||
if norm_name == p_lower {
|
||||
return true;
|
||||
}
|
||||
if norm_rel.trim_start_matches('/').to_ascii_lowercase() == p_lower.trim_start_matches('/') {
|
||||
if norm_rel.trim_start_matches('/').to_ascii_lowercase()
|
||||
== p_lower.trim_start_matches('/')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -176,8 +179,8 @@ fn read_chunk_buffer(file: &mut File, buf: &mut [u8]) -> std::io::Result<usize>
|
||||
|
||||
/// Setzt den Modifikationszeitstempel einer lokalen Datei via std::fs::FileTimes.
|
||||
fn set_local_file_mtime(file: &File, mtime_secs: u64) {
|
||||
let times = std::fs::FileTimes::new()
|
||||
.set_modified(UNIX_EPOCH + Duration::from_secs(mtime_secs));
|
||||
let times =
|
||||
std::fs::FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(mtime_secs));
|
||||
let _ = file.set_times(times);
|
||||
}
|
||||
|
||||
@@ -206,7 +209,10 @@ pub fn ensure_vault_dir_tree(
|
||||
let children = db.list_children_in_vault(current_id, vault_id, dek)?;
|
||||
if let Some(existing) = children.into_iter().find(|c| c.name == segment) {
|
||||
if !existing.is_dir {
|
||||
bail!("Pfad-Konflikt: '{}' existiert im Container bereits als Datei", segment);
|
||||
bail!(
|
||||
"Pfad-Konflikt: '{}' existiert im Container bereits als Datei",
|
||||
segment
|
||||
);
|
||||
}
|
||||
current_id = existing.id;
|
||||
current_node = existing;
|
||||
@@ -234,8 +240,12 @@ pub fn sync_single_file_to_vault(
|
||||
) -> Result<FileTransferResult> {
|
||||
validate_node_name(file_name)?;
|
||||
|
||||
let meta = fs::metadata(local_path)
|
||||
.with_context(|| format!("Konnte Metadaten für '{}' nicht lesen", local_path.display()))?;
|
||||
let meta = fs::metadata(local_path).with_context(|| {
|
||||
format!(
|
||||
"Konnte Metadaten für '{}' nicht lesen",
|
||||
local_path.display()
|
||||
)
|
||||
})?;
|
||||
let local_size = meta.len();
|
||||
let local_mtime = meta
|
||||
.modified()
|
||||
@@ -250,7 +260,10 @@ pub fn sync_single_file_to_vault(
|
||||
if let Some(ref node) = existing_node {
|
||||
db.assert_not_carrier(node.id)?;
|
||||
if node.is_dir {
|
||||
bail!("Pfad-Konflikt: '{}' existiert im Tresor als Ordner", file_name);
|
||||
bail!(
|
||||
"Pfad-Konflikt: '{}' existiert im Tresor als Ordner",
|
||||
file_name
|
||||
);
|
||||
}
|
||||
|
||||
// Fast Check: Wenn Größe und mtime identisch sind, überspringen (ohne --checksum)
|
||||
@@ -278,7 +291,8 @@ pub fn sync_single_file_to_vault(
|
||||
let node_id = match existing_node {
|
||||
Some(n) => n.id,
|
||||
None => {
|
||||
let new_node = db.create_node_in_vault(vault_id, parent_node_id, file_name, false, dek)?;
|
||||
let new_node =
|
||||
db.create_node_in_vault(vault_id, parent_node_id, file_name, false, dek)?;
|
||||
new_node.id
|
||||
}
|
||||
};
|
||||
@@ -370,8 +384,12 @@ pub fn sync_single_file_to_host(
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let mut out_file = File::create(local_path)
|
||||
.with_context(|| format!("Konnte Zieldatei '{}' nicht erstellen", local_path.display()))?;
|
||||
let mut out_file = File::create(local_path).with_context(|| {
|
||||
format!(
|
||||
"Konnte Zieldatei '{}' nicht erstellen",
|
||||
local_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let total_chunks = if node.size == 0 {
|
||||
0
|
||||
@@ -381,10 +399,22 @@ pub fn sync_single_file_to_host(
|
||||
|
||||
for idx in 0..total_chunks {
|
||||
if let Some(record) = db.read_chunk(node.id, idx)? {
|
||||
let plaintext = decrypt_chunk(dek, node.id, idx, &record.ciphertext, &record.nonce, &record.tag, version)?;
|
||||
let plaintext = decrypt_chunk(
|
||||
dek,
|
||||
node.id,
|
||||
idx,
|
||||
&record.ciphertext,
|
||||
&record.nonce,
|
||||
&record.tag,
|
||||
version,
|
||||
)?;
|
||||
out_file.write_all(&plaintext)?;
|
||||
} else {
|
||||
bail!("Beschädigte Datei im Tresor: Chunk #{} für Knoten '{}' fehlt", idx, node.name);
|
||||
bail!(
|
||||
"Beschädigte Datei im Tresor: Chunk #{} für Knoten '{}' fehlt",
|
||||
idx,
|
||||
node.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,10 +439,14 @@ pub fn run_sync(
|
||||
|
||||
match options.direction {
|
||||
SyncDirection::Push => {
|
||||
sync_push(db, vault_id, dek, version, source_arg, target_arg, options, &mut stats)?;
|
||||
sync_push(
|
||||
db, vault_id, dek, version, source_arg, target_arg, options, &mut stats,
|
||||
)?;
|
||||
}
|
||||
SyncDirection::Pull => {
|
||||
sync_pull(db, vault_id, dek, version, source_arg, target_arg, options, &mut stats)?;
|
||||
sync_pull(
|
||||
db, vault_id, dek, version, source_arg, target_arg, options, &mut stats,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,7 +470,11 @@ fn sync_push(
|
||||
bail!("Lokale Quelle '{}' existiert nicht.", source_str);
|
||||
}
|
||||
|
||||
let target_vault_dir = if target_str.is_empty() { "/" } else { target_str };
|
||||
let target_vault_dir = if target_str.is_empty() {
|
||||
"/"
|
||||
} else {
|
||||
target_str
|
||||
};
|
||||
|
||||
if local_source.is_file() {
|
||||
let file_name = local_source
|
||||
@@ -469,14 +507,24 @@ fn sync_push(
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} Übertragen: {} ({})", ui::green("[+]"), file_name, ui::format_bytes(size));
|
||||
println!(
|
||||
" {} Übertragen: {} ({})",
|
||||
ui::green("[+]"),
|
||||
file_name,
|
||||
ui::format_bytes(size)
|
||||
);
|
||||
}
|
||||
}
|
||||
FileTransferResult::DryRunTransferred { size } => {
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} [DRY-RUN] Würde übertragen: {} ({})", ui::yellow("[~]"), file_name, ui::format_bytes(size));
|
||||
println!(
|
||||
" {} [DRY-RUN] Würde übertragen: {} ({})",
|
||||
ui::yellow("[~]"),
|
||||
file_name,
|
||||
ui::format_bytes(size)
|
||||
);
|
||||
}
|
||||
}
|
||||
FileTransferResult::Skipped { .. } => {
|
||||
@@ -556,11 +604,20 @@ fn collect_and_push_dir(
|
||||
if path.is_dir() {
|
||||
// Ordner im Tresor anlegen falls nötig
|
||||
let children = db.list_children_in_vault(current_vault_parent_id, vault_id, dek)?;
|
||||
let sub_dir_node = match children.into_iter().find(|c| c.name == file_name && c.is_dir) {
|
||||
let sub_dir_node = match children
|
||||
.into_iter()
|
||||
.find(|c| c.name == file_name && c.is_dir)
|
||||
{
|
||||
Some(n) => n,
|
||||
None => {
|
||||
if !options.dry_run {
|
||||
db.create_node_in_vault(vault_id, current_vault_parent_id, &file_name, true, dek)?
|
||||
db.create_node_in_vault(
|
||||
vault_id,
|
||||
current_vault_parent_id,
|
||||
&file_name,
|
||||
true,
|
||||
dek,
|
||||
)?
|
||||
} else {
|
||||
// Dummy für dry-run
|
||||
NodeRecord {
|
||||
@@ -605,14 +662,24 @@ fn collect_and_push_dir(
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} Übertragen: {} ({})", ui::green("[+]"), rel_path, ui::format_bytes(size));
|
||||
println!(
|
||||
" {} Übertragen: {} ({})",
|
||||
ui::green("[+]"),
|
||||
rel_path,
|
||||
ui::format_bytes(size)
|
||||
);
|
||||
}
|
||||
}
|
||||
FileTransferResult::DryRunTransferred { size } => {
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} [DRY-RUN] Würde übertragen: {} ({})", ui::yellow("[~]"), rel_path, ui::format_bytes(size));
|
||||
println!(
|
||||
" {} [DRY-RUN] Würde übertragen: {} ({})",
|
||||
ui::yellow("[~]"),
|
||||
rel_path,
|
||||
ui::format_bytes(size)
|
||||
);
|
||||
}
|
||||
}
|
||||
FileTransferResult::Skipped { .. } => {
|
||||
@@ -644,7 +711,10 @@ pub fn delete_orphans_in_vault(
|
||||
|
||||
for child in children {
|
||||
// S-03 Carrier Guard: Trägerdatei und übergeordnete Verzeichnisse niemals löschen!
|
||||
if carrier_id > 0 && (child.id == carrier_id || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) {
|
||||
if carrier_id > 0
|
||||
&& (child.id == carrier_id
|
||||
|| db.is_descendant_of(carrier_id, child.id).unwrap_or(false))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -658,7 +728,11 @@ pub fn delete_orphans_in_vault(
|
||||
stats.files_deleted += 1;
|
||||
if dry_run {
|
||||
if !quiet {
|
||||
println!(" {} [DRY-RUN] Würde aus Tresor löschen: {}", ui::red("[-]"), child_rel);
|
||||
println!(
|
||||
" {} [DRY-RUN] Würde aus Tresor löschen: {}",
|
||||
ui::red("[-]"),
|
||||
child_rel
|
||||
);
|
||||
}
|
||||
} else {
|
||||
db.delete_node(child.id)?;
|
||||
@@ -724,20 +798,34 @@ fn sync_pull(
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} Wiederhergestellt: {} ({})", ui::green("[+]"), local_file_path.display(), ui::format_bytes(size));
|
||||
println!(
|
||||
" {} Wiederhergestellt: {} ({})",
|
||||
ui::green("[+]"),
|
||||
local_file_path.display(),
|
||||
ui::format_bytes(size)
|
||||
);
|
||||
}
|
||||
}
|
||||
FileTransferResult::DryRunTransferred { size } => {
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} [DRY-RUN] Würde wiederherstellen: {} ({})", ui::yellow("[~]"), local_file_path.display(), ui::format_bytes(size));
|
||||
println!(
|
||||
" {} [DRY-RUN] Würde wiederherstellen: {} ({})",
|
||||
ui::yellow("[~]"),
|
||||
local_file_path.display(),
|
||||
ui::format_bytes(size)
|
||||
);
|
||||
}
|
||||
}
|
||||
FileTransferResult::Skipped { .. } => {
|
||||
stats.files_skipped += 1;
|
||||
if !options.quiet {
|
||||
println!(" {} Aktuell (übersprungen): {}", ui::dim("[=]"), local_file_path.display());
|
||||
println!(
|
||||
" {} Aktuell (übersprungen): {}",
|
||||
ui::dim("[=]"),
|
||||
local_file_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -795,7 +883,10 @@ fn collect_and_pull_dir(
|
||||
|
||||
for child in children {
|
||||
// R-02 Carrier Guard: Trägerdatei niemals auf den Host spiegeln / herausziehen
|
||||
if carrier_id > 0 && (child.id == carrier_id || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) {
|
||||
if carrier_id > 0
|
||||
&& (child.id == carrier_id
|
||||
|| db.is_descendant_of(carrier_id, child.id).unwrap_or(false))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -816,7 +907,10 @@ fn collect_and_pull_dir(
|
||||
for comp in Path::new(&child_rel).components() {
|
||||
match comp {
|
||||
std::path::Component::Normal(_) => {}
|
||||
_ => bail!("Path traversal Versuch erkannt in relativem Pfad: '{}'", child_rel),
|
||||
_ => bail!(
|
||||
"Path traversal Versuch erkannt in relativem Pfad: '{}'",
|
||||
child_rel
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,7 +918,10 @@ fn collect_and_pull_dir(
|
||||
let local_child_path = local_target_base.join(&child_rel.replace('/', "\\"));
|
||||
|
||||
if !local_child_path.starts_with(local_target_base) {
|
||||
bail!("Path traversal Versuch erkannt: '{}' bricht aus Zielverzeichnis aus", child_rel);
|
||||
bail!(
|
||||
"Path traversal Versuch erkannt: '{}' bricht aus Zielverzeichnis aus",
|
||||
child_rel
|
||||
);
|
||||
}
|
||||
|
||||
if child.is_dir {
|
||||
@@ -859,14 +956,24 @@ fn collect_and_pull_dir(
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} Wiederhergestellt: {} ({})", ui::green("[+]"), child_rel, ui::format_bytes(size));
|
||||
println!(
|
||||
" {} Wiederhergestellt: {} ({})",
|
||||
ui::green("[+]"),
|
||||
child_rel,
|
||||
ui::format_bytes(size)
|
||||
);
|
||||
}
|
||||
}
|
||||
FileTransferResult::DryRunTransferred { size } => {
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} [DRY-RUN] Würde wiederherstellen: {} ({})", ui::yellow("[~]"), child_rel, ui::format_bytes(size));
|
||||
println!(
|
||||
" {} [DRY-RUN] Würde wiederherstellen: {} ({})",
|
||||
ui::yellow("[~]"),
|
||||
child_rel,
|
||||
ui::format_bytes(size)
|
||||
);
|
||||
}
|
||||
}
|
||||
FileTransferResult::Skipped { .. } => {
|
||||
@@ -908,7 +1015,11 @@ fn delete_orphans_on_host(
|
||||
if path.is_dir() {
|
||||
if dry_run {
|
||||
if !quiet {
|
||||
println!(" {} [DRY-RUN] Würde lokalen Ordner löschen: {}", ui::red("[-]"), rel_path);
|
||||
println!(
|
||||
" {} [DRY-RUN] Würde lokalen Ordner löschen: {}",
|
||||
ui::red("[-]"),
|
||||
rel_path
|
||||
);
|
||||
}
|
||||
} else {
|
||||
fs::remove_dir_all(&path)?;
|
||||
@@ -919,7 +1030,11 @@ fn delete_orphans_on_host(
|
||||
} else {
|
||||
if dry_run {
|
||||
if !quiet {
|
||||
println!(" {} [DRY-RUN] Würde lokale Datei löschen: {}", ui::red("[-]"), rel_path);
|
||||
println!(
|
||||
" {} [DRY-RUN] Würde lokale Datei löschen: {}",
|
||||
ui::red("[-]"),
|
||||
rel_path
|
||||
);
|
||||
}
|
||||
} else {
|
||||
fs::remove_file(&path)?;
|
||||
@@ -951,10 +1066,18 @@ mod tests {
|
||||
];
|
||||
|
||||
assert!(is_excluded("file.tmp", "sub/file.tmp", &patterns));
|
||||
assert!(is_excluded("download.crdownload", "download.crdownload", &patterns));
|
||||
assert!(is_excluded(
|
||||
"download.crdownload",
|
||||
"download.crdownload",
|
||||
&patterns
|
||||
));
|
||||
assert!(is_excluded("Thumbs.db", "Thumbs.db", &patterns));
|
||||
assert!(is_excluded("backup_2026.tar", "backup_2026.tar", &patterns));
|
||||
assert!(!is_excluded("important.doc", "sub/important.doc", &patterns));
|
||||
assert!(!is_excluded(
|
||||
"important.doc",
|
||||
"sub/important.doc",
|
||||
&patterns
|
||||
));
|
||||
assert!(!is_excluded("video.mp4", "video.mp4", &patterns));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,13 +131,25 @@ pub fn step(num: u8, total: u8, icon: &str, msg: &str) {
|
||||
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!(
|
||||
"{}",
|
||||
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("├─────────────────────────────────────────────────────────────┤"));
|
||||
println!(
|
||||
"{}",
|
||||
yellow("├─────────────────────────────────────────────────────────────┤")
|
||||
);
|
||||
|
||||
for row in 0..8 {
|
||||
let w1 = if row < words.len() {
|
||||
@@ -158,7 +170,10 @@ pub fn print_recovery_phrase_card(phrase: &str) {
|
||||
println!("│ {:<18} {:<18} {:<18} │", cyan(&w1), cyan(&w2), cyan(&w3));
|
||||
}
|
||||
|
||||
println!("{}", yellow("└─────────────────────────────────────────────────────────────┘"));
|
||||
println!(
|
||||
"{}",
|
||||
yellow("└─────────────────────────────────────────────────────────────┘")
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
@@ -204,7 +219,10 @@ pub fn print_verification_report(report: &crate::verify::VerificationReport) {
|
||||
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);
|
||||
println!(
|
||||
" - Verifiziert: {:.2} MB (vollständig entschlüsselt)",
|
||||
mb
|
||||
);
|
||||
}
|
||||
|
||||
if !report.errors.is_empty() {
|
||||
@@ -217,10 +235,15 @@ pub fn print_verification_report(report: &crate::verify::VerificationReport) {
|
||||
|
||||
println!();
|
||||
if report.is_healthy() {
|
||||
println!(" {}", green("✔ Keine Beschädigungen oder Bitrot festgestellt. Der Container ist integer."));
|
||||
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!(
|
||||
" {}",
|
||||
red("✖ ACHTUNG: Der Container weist Beschädigungen auf! Bitte Backup prüfen.")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
|
||||
+69
-25
@@ -65,11 +65,7 @@ impl Default for UpgradeOptions {
|
||||
}
|
||||
|
||||
/// Überprüft eine Minisign-Signatur für gegebene Binärdaten mit dem angegebenen Minisign Public Key (S-02).
|
||||
pub fn verify_minisign_signature(
|
||||
data: &[u8],
|
||||
sig_str: &str,
|
||||
pubkey_str: &str,
|
||||
) -> Result<()> {
|
||||
pub fn verify_minisign_signature(data: &[u8], sig_str: &str, pubkey_str: &str) -> Result<()> {
|
||||
let pubkey = minisign_verify::PublicKey::from_base64(pubkey_str)
|
||||
.map_err(|e| anyhow::anyhow!("Ungültiger öffentlicher Minisign-Schlüssel: {e}"))?;
|
||||
let signature = minisign_verify::Signature::decode(sig_str)
|
||||
@@ -138,7 +134,10 @@ pub fn fetch_latest_release(base_url: &str) -> Result<GiteaRelease> {
|
||||
let api_url = format!("{}/api/v1/repos/harald/sanctum/releases/latest", base);
|
||||
|
||||
let resp = ureq::get(&api_url)
|
||||
.set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION")))
|
||||
.set(
|
||||
"User-Agent",
|
||||
&format!("sanctum/{}", env!("CARGO_PKG_VERSION")),
|
||||
)
|
||||
.set("Accept", "application/json")
|
||||
.timeout(Duration::from_secs(15))
|
||||
.call()
|
||||
@@ -154,7 +153,10 @@ pub fn fetch_latest_release(base_url: &str) -> Result<GiteaRelease> {
|
||||
/// Lädt eine Datei von einer URL als Text herunter.
|
||||
pub fn fetch_text(url: &str) -> Result<String> {
|
||||
let resp = ureq::get(url)
|
||||
.set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION")))
|
||||
.set(
|
||||
"User-Agent",
|
||||
&format!("sanctum/{}", env!("CARGO_PKG_VERSION")),
|
||||
)
|
||||
.timeout(Duration::from_secs(15))
|
||||
.call()
|
||||
.with_context(|| format!("Fehler beim Herunterladen von '{}'", url))?;
|
||||
@@ -212,7 +214,10 @@ pub fn download_and_verify_binary(
|
||||
expected_size: u64,
|
||||
) -> Result<PathBuf> {
|
||||
let resp = ureq::get(download_url)
|
||||
.set("User-Agent", &format!("sanctum/{}", env!("CARGO_PKG_VERSION")))
|
||||
.set(
|
||||
"User-Agent",
|
||||
&format!("sanctum/{}", env!("CARGO_PKG_VERSION")),
|
||||
)
|
||||
.timeout(Duration::from_secs(120))
|
||||
.call()
|
||||
.with_context(|| format!("Fehler beim Starten des Downloads von '{}'", download_url))?;
|
||||
@@ -285,7 +290,10 @@ pub fn download_and_verify_binary(
|
||||
);
|
||||
}
|
||||
|
||||
println!(" • SHA-256: {} (verifiziert)", ui::green(&calculated_hash[..16]));
|
||||
println!(
|
||||
" • SHA-256: {} (verifiziert)",
|
||||
ui::green(&calculated_hash[..16])
|
||||
);
|
||||
|
||||
let (_, path) = temp_file
|
||||
.keep()
|
||||
@@ -324,12 +332,16 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ Sanctum — In-Place Upgrade & Aktualisierungsdienst │");
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" • Aktuelle Version: {}", ui::cyan(&format!("v{}", current_version)));
|
||||
println!(
|
||||
" • Aktuelle Version: {}",
|
||||
ui::cyan(&format!("v{}", current_version))
|
||||
);
|
||||
println!(" • Server: {}", options.base_url);
|
||||
println!();
|
||||
|
||||
// S-02: Prüfung auf abweichende Server-URL
|
||||
let is_official_server = options.base_url.trim_end_matches('/') == OFFICIAL_UPGRADE_URL.trim_end_matches('/');
|
||||
let is_official_server =
|
||||
options.base_url.trim_end_matches('/') == OFFICIAL_UPGRADE_URL.trim_end_matches('/');
|
||||
if !is_official_server {
|
||||
if !options.insecure_url {
|
||||
bail!(
|
||||
@@ -389,12 +401,22 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
|
||||
}
|
||||
|
||||
// 2. Neuestes Release abfragen
|
||||
println!(" {} Suche nach neuesten Releases auf Gitea ...", ui::dim("[-]"));
|
||||
println!(
|
||||
" {} Suche nach neuesten Releases auf Gitea ...",
|
||||
ui::dim("[-]")
|
||||
);
|
||||
let release = fetch_latest_release(&options.base_url)?;
|
||||
let remote_tag = &release.tag_name;
|
||||
let is_newer = is_newer_version(current_version, remote_tag);
|
||||
|
||||
println!(" • Neueste Version: {}", if is_newer { ui::green(remote_tag) } else { ui::cyan(remote_tag) });
|
||||
println!(
|
||||
" • Neueste Version: {}",
|
||||
if is_newer {
|
||||
ui::green(remote_tag)
|
||||
} else {
|
||||
ui::cyan(remote_tag)
|
||||
}
|
||||
);
|
||||
println!(" • Release-Name: {}", release.name);
|
||||
if let Some(ref pub_at) = release.published_at {
|
||||
println!(" • Veröffentlicht: {}", pub_at);
|
||||
@@ -459,27 +481,46 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
|
||||
})?;
|
||||
|
||||
// 4. Prüfsummen und Signatur herunterladen und kryptografisch prüfen (S-02)
|
||||
println!(" {} Lade Prüfsummen ({}) herunter ...", ui::dim("[-]"), CHECKSUM_ASSET_NAME);
|
||||
println!(
|
||||
" {} Lade Prüfsummen ({}) herunter ...",
|
||||
ui::dim("[-]"),
|
||||
CHECKSUM_ASSET_NAME
|
||||
);
|
||||
let sums_content = fetch_text(&checksum_asset.browser_download_url)?;
|
||||
|
||||
println!(" {} Lade Minisign-Signatur ({}) herunter ...", ui::dim("[-]"), CHECKSUM_SIG_ASSET_NAME);
|
||||
println!(
|
||||
" {} Lade Minisign-Signatur ({}) herunter ...",
|
||||
ui::dim("[-]"),
|
||||
CHECKSUM_SIG_ASSET_NAME
|
||||
);
|
||||
let sig_content = fetch_text(&sig_asset.browser_download_url)?;
|
||||
|
||||
println!(" {} Verifiziere Minisign-Signatur gegen eingebetteten Herstellerschlüssel ...", ui::dim("[-]"));
|
||||
println!(
|
||||
" {} Verifiziere Minisign-Signatur gegen eingebetteten Herstellerschlüssel ...",
|
||||
ui::dim("[-]")
|
||||
);
|
||||
verify_minisign_signature(sums_content.as_bytes(), &sig_content, SANCTUM_RELEASE_PUBKEY)
|
||||
.context("Minisign-Signaturprüfung für Prüfsummendatei FEHLGESCHLAGEN! Release ist nicht vertrauenswürdig.")?;
|
||||
println!(" • Signatur: {} (Minisign Ed25519)", ui::green("Gültig"));
|
||||
println!(
|
||||
" • Signatur: {} (Minisign Ed25519)",
|
||||
ui::green("Gültig")
|
||||
);
|
||||
|
||||
let expected_hash = parse_checksum_for_asset(&sums_content, TARGET_ASSET_NAME).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Prüfsummendatei enthält keinen SHA-256 Eintrag für '{}'!",
|
||||
TARGET_ASSET_NAME
|
||||
)
|
||||
})?;
|
||||
let expected_hash =
|
||||
parse_checksum_for_asset(&sums_content, TARGET_ASSET_NAME).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Prüfsummendatei enthält keinen SHA-256 Eintrag für '{}'!",
|
||||
TARGET_ASSET_NAME
|
||||
)
|
||||
})?;
|
||||
|
||||
// 5. Interaktive Bestätigung (sofern nicht -y / --yes)
|
||||
if !options.yes {
|
||||
println!(" • Ziel-Binary: {} ({})", target_asset.name, ui::format_bytes(target_asset.size));
|
||||
println!(
|
||||
" • Ziel-Binary: {} ({})",
|
||||
target_asset.name,
|
||||
ui::format_bytes(target_asset.size)
|
||||
);
|
||||
println!(" • Erwarteter Hash: {}...", &expected_hash[..16]);
|
||||
println!();
|
||||
print!(
|
||||
@@ -513,7 +554,10 @@ pub fn run_upgrade(options: &UpgradeOptions) -> Result<()> {
|
||||
|
||||
println!();
|
||||
println!("┌─────────────────────────────────────────────────────────────┐");
|
||||
println!("│ ✔ Sanctum wurde erfolgreich auf {} aktualisiert! │", remote_tag);
|
||||
println!(
|
||||
"│ ✔ Sanctum wurde erfolgreich auf {} aktualisiert! │",
|
||||
remote_tag
|
||||
);
|
||||
println!("└─────────────────────────────────────────────────────────────┘");
|
||||
println!(" • Pfad: {}", updated_exe.display());
|
||||
println!(" • Neue Version: {}", ui::green(remote_tag));
|
||||
|
||||
+62
-21
@@ -43,11 +43,13 @@ pub fn verify_container(
|
||||
full_chunks: bool,
|
||||
) -> Result<VerificationReport> {
|
||||
if !container_path.exists() {
|
||||
bail!("Containerdatei '{}' existiert nicht.", container_path.display());
|
||||
bail!(
|
||||
"Containerdatei '{}' existiert nicht.",
|
||||
container_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let db = Database::open(container_path)
|
||||
.context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
let db = Database::open(container_path).context("Konnte Container-Datenbank nicht öffnen")?;
|
||||
|
||||
let mut report = VerificationReport {
|
||||
container_path: container_path.display().to_string(),
|
||||
@@ -67,7 +69,8 @@ pub fn verify_container(
|
||||
};
|
||||
|
||||
// 1. SQLite B-Tree & Foreign Key Prüfung
|
||||
let sqlite_issues = db.run_sqlite_integrity_check()
|
||||
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;
|
||||
@@ -96,13 +99,16 @@ pub fn verify_container(
|
||||
};
|
||||
|
||||
// 3. Node-Hierarchie & Strukturprüfung
|
||||
let (dirs, files, chunks_count) = db.count_nodes_and_chunks()
|
||||
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")?;
|
||||
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();
|
||||
@@ -114,23 +120,33 @@ pub fn verify_container(
|
||||
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());
|
||||
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());
|
||||
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());
|
||||
report
|
||||
.errors
|
||||
.push("Root-Knoten (id=1) fehlt in der nodes-Tabelle!".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(root2) = node_map.get(&2) {
|
||||
if !root2.is_dir {
|
||||
report.errors.push("Root-Knoten (id=2) ist nicht als Verzeichnis markiert!".to_string());
|
||||
report
|
||||
.errors
|
||||
.push("Root-Knoten (id=2) ist nicht als Verzeichnis markiert!".to_string());
|
||||
}
|
||||
if root2.parent_id.is_some() {
|
||||
report.errors.push("Root-Knoten (id=2) darf keinen Parent haben!".to_string());
|
||||
report
|
||||
.errors
|
||||
.push("Root-Knoten (id=2) darf keinen Parent haben!".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,10 +248,14 @@ pub fn verify_container(
|
||||
};
|
||||
|
||||
// 4. Kryptografische Chunk- & AEAD-Authentifizierungsprüfung
|
||||
let chunk_headers = db.list_all_chunk_headers()
|
||||
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);
|
||||
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) {
|
||||
@@ -319,7 +339,8 @@ mod tests {
|
||||
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.init_schema(&salt, &kdf_params, &wrapped_dek, &nonce, &tag)
|
||||
.unwrap();
|
||||
|
||||
// Verzeichnis & Datei anlegen
|
||||
let folder = db.create_node(1, "photos", true).unwrap();
|
||||
@@ -334,12 +355,21 @@ mod tests {
|
||||
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.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!(
|
||||
report.is_healthy(),
|
||||
"Container must be healthy, report: {:?}",
|
||||
report
|
||||
);
|
||||
assert_eq!(report.total_files, 1);
|
||||
assert_eq!(report.total_dirs, 3); // Root 1 + Root 2 (Dual-Vault) + photos
|
||||
assert_eq!(report.total_chunks, 2);
|
||||
@@ -371,7 +401,8 @@ mod tests {
|
||||
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.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";
|
||||
@@ -387,14 +418,24 @@ mod tests {
|
||||
conn.execute(
|
||||
"UPDATE chunks SET ciphertext = ?1 WHERE node_id = ?2 AND chunk_index = 0",
|
||||
rusqlite::params![corrupted_ct, file.id],
|
||||
).unwrap();
|
||||
)
|
||||
.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")));
|
||||
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);
|
||||
}
|
||||
|
||||
+75
-62
@@ -26,14 +26,8 @@ use crate::storage::{Database, NodeRecord};
|
||||
pub fn is_leak_file(filename: &str) -> bool {
|
||||
let lower = filename.trim().to_ascii_lowercase();
|
||||
match lower.as_str() {
|
||||
"thumbs.db"
|
||||
| "ehthumbs.db"
|
||||
| "ehthumbs_vista.db"
|
||||
| "desktop.ini"
|
||||
| "folder.jpg"
|
||||
| "albumartsmall.jpg"
|
||||
| "autorun.inf"
|
||||
| ".ds_store" => true,
|
||||
"thumbs.db" | "ehthumbs.db" | "ehthumbs_vista.db" | "desktop.ini" | "folder.jpg"
|
||||
| "albumartsmall.jpg" | "autorun.inf" | ".ds_store" => true,
|
||||
_ => {
|
||||
if lower.starts_with("albumart") && (lower.ends_with(".jpg") || lower.ends_with(".ini"))
|
||||
{
|
||||
@@ -167,7 +161,6 @@ impl SanctumFile {
|
||||
self.last_activity.store(now, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
|
||||
/// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück
|
||||
/// und aktualisiert Dateigröße und Modifikationszeitstempel atomar in einer Transaktion (CHAOS-01).
|
||||
/// Bei Fehlern (z. B. Disk Full) wird der Cache sauber invalidiert (CHAOS-03).
|
||||
@@ -179,10 +172,12 @@ impl SanctumFile {
|
||||
|
||||
if let Some((idx, ref data, true)) = self.cached_chunk {
|
||||
let (ciphertext, nonce, tag) =
|
||||
encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(|e| {
|
||||
error!("Verschlüsselungsfehler beim Chunk-Flush: {e}");
|
||||
FsError::GeneralFailure
|
||||
})?;
|
||||
encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(
|
||||
|e| {
|
||||
error!("Verschlüsselungsfehler beim Chunk-Flush: {e}");
|
||||
FsError::GeneralFailure
|
||||
},
|
||||
)?;
|
||||
|
||||
if let Err(e) = self.db.write_chunk_and_update_size(
|
||||
self.node_id,
|
||||
@@ -207,7 +202,10 @@ impl SanctumFile {
|
||||
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
|
||||
} else if self.meta.size != self.file_size {
|
||||
// Falls kein Chunk dirty war, aber sich z. B. die Dateigröße durch Truncate geändert hat
|
||||
if let Err(e) = self.db.update_node_size_and_time(self.node_id, self.file_size, now) {
|
||||
if let Err(e) = self
|
||||
.db
|
||||
.update_node_size_and_time(self.node_id, self.file_size, now)
|
||||
{
|
||||
error!("Fehler beim Aktualisieren der Knotengröße: {e}");
|
||||
return Err(FsError::GeneralFailure);
|
||||
}
|
||||
@@ -263,7 +261,10 @@ impl SanctumFile {
|
||||
impl Drop for SanctumFile {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = self.flush_cached_chunk_and_size() {
|
||||
warn!("Fehler beim automatischen Flush im SanctumFile::drop: {:?}", e);
|
||||
warn!(
|
||||
"Fehler beim automatischen Flush im SanctumFile::drop: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
if let Some((_, ref mut data, _)) = self.cached_chunk {
|
||||
data.zeroize();
|
||||
@@ -354,7 +355,12 @@ impl DavFile for SanctumFile {
|
||||
}
|
||||
|
||||
// Wenn der Chunk exakt 1 MB erreicht hat, sofort flushen, um RAM zu schonen
|
||||
if self.cached_chunk.as_ref().map(|(_, d, _)| d.len() >= CHUNK_SIZE).unwrap_or(false) {
|
||||
if self
|
||||
.cached_chunk
|
||||
.as_ref()
|
||||
.map(|(_, d, _)| d.len() >= CHUNK_SIZE)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
self.flush_cached_chunk_and_size()?;
|
||||
}
|
||||
|
||||
@@ -633,14 +639,19 @@ impl DavFileSystem for SanctumFs {
|
||||
}
|
||||
|
||||
self.touch();
|
||||
debug!("VFS open aufgerufen: path='{}', options={:?}", path_str, options);
|
||||
debug!(
|
||||
"VFS open aufgerufen: path='{}', options={:?}",
|
||||
path_str, options
|
||||
);
|
||||
|
||||
let existing_node = self.resolve_path(&path_str)?;
|
||||
|
||||
let node = match existing_node {
|
||||
Some(n) => {
|
||||
// Schutz der Trägerdatei im Decoy Vault: Keine Schreib- oder Truncate-Operationen erlaubt!
|
||||
if self.carrier_node_id == Some(n.id) && (options.write || options.truncate || options.append) {
|
||||
if self.carrier_node_id == Some(n.id)
|
||||
&& (options.write || options.truncate || options.append)
|
||||
{
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
|
||||
@@ -674,9 +685,7 @@ impl DavFileSystem for SanctumFs {
|
||||
}
|
||||
None => {
|
||||
if options.create || options.create_new {
|
||||
let parent = self
|
||||
.resolve_path(parent_path)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
let parent = self.resolve_path(parent_path)?.ok_or(FsError::NotFound)?;
|
||||
|
||||
if !parent.is_dir {
|
||||
return Err(FsError::Forbidden);
|
||||
@@ -712,9 +721,7 @@ impl DavFileSystem for SanctumFs {
|
||||
|
||||
Box::pin(async move {
|
||||
let path_str = Self::path_to_str(path);
|
||||
let node = self
|
||||
.resolve_path(&path_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
|
||||
|
||||
if !node.is_dir {
|
||||
return Err(FsError::Forbidden);
|
||||
@@ -749,9 +756,7 @@ impl DavFileSystem for SanctumFs {
|
||||
|
||||
Box::pin(async move {
|
||||
let path_str = Self::path_to_str(path);
|
||||
let node = self
|
||||
.resolve_path(&path_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
|
||||
|
||||
let meta = SanctumMetaData {
|
||||
is_dir: node.is_dir,
|
||||
@@ -789,9 +794,7 @@ impl DavFileSystem for SanctumFs {
|
||||
return Err(FsError::Exists);
|
||||
}
|
||||
|
||||
let parent = self
|
||||
.resolve_path(parent_path)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
let parent = self.resolve_path(parent_path)?.ok_or(FsError::NotFound)?;
|
||||
|
||||
if !parent.is_dir {
|
||||
return Err(FsError::Forbidden);
|
||||
@@ -811,9 +814,7 @@ impl DavFileSystem for SanctumFs {
|
||||
Box::pin(async move {
|
||||
self.touch();
|
||||
let path_str = Self::path_to_str(path);
|
||||
let node = self
|
||||
.resolve_path(&path_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
|
||||
|
||||
if !node.is_dir {
|
||||
return Err(FsError::Forbidden);
|
||||
@@ -826,7 +827,11 @@ impl DavFileSystem for SanctumFs {
|
||||
|
||||
// Schutz der Trägerdatei im Decoy Vault: Verzeichnis darf nicht gelöscht werden, wenn es den Carrier enthält!
|
||||
if let Some(carrier_id) = self.carrier_node_id {
|
||||
if self.db.is_descendant_of(carrier_id, node.id).map_err(|_| FsError::GeneralFailure)? {
|
||||
if self
|
||||
.db
|
||||
.is_descendant_of(carrier_id, node.id)
|
||||
.map_err(|_| FsError::GeneralFailure)?
|
||||
{
|
||||
return Err(FsError::Forbidden);
|
||||
}
|
||||
}
|
||||
@@ -847,9 +852,7 @@ impl DavFileSystem for SanctumFs {
|
||||
Box::pin(async move {
|
||||
self.touch();
|
||||
let path_str = Self::path_to_str(path);
|
||||
let node = self
|
||||
.resolve_path(&path_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
|
||||
|
||||
if node.is_dir {
|
||||
return Err(FsError::Forbidden);
|
||||
@@ -868,11 +871,7 @@ impl DavFileSystem for SanctumFs {
|
||||
})
|
||||
}
|
||||
|
||||
fn rename<'a>(
|
||||
&'a self,
|
||||
from: &'a DavPath,
|
||||
to: &'a DavPath,
|
||||
) -> FsFuture<'a, ()> {
|
||||
fn rename<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> {
|
||||
if let Some(ref cfs) = self.carrier_fs {
|
||||
return cfs.rename(from, to);
|
||||
}
|
||||
@@ -882,9 +881,7 @@ impl DavFileSystem for SanctumFs {
|
||||
let from_str = Self::path_to_str(from);
|
||||
let to_str = Self::path_to_str(to);
|
||||
|
||||
let node = self
|
||||
.resolve_path(&from_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
let node = self.resolve_path(&from_str)?.ok_or(FsError::NotFound)?;
|
||||
|
||||
// Schutz der Trägerdatei im Decoy Vault: Umbenennen verboten!
|
||||
if self.carrier_node_id == Some(node.id) {
|
||||
@@ -925,19 +922,13 @@ impl DavFileSystem for SanctumFs {
|
||||
})
|
||||
}
|
||||
|
||||
fn copy<'a>(
|
||||
&'a self,
|
||||
from: &'a DavPath,
|
||||
to: &'a DavPath,
|
||||
) -> FsFuture<'a, ()> {
|
||||
fn copy<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
self.touch();
|
||||
let from_str = Self::path_to_str(from);
|
||||
let to_str = Self::path_to_str(to);
|
||||
|
||||
let node = self
|
||||
.resolve_path(&from_str)?
|
||||
.ok_or(FsError::NotFound)?;
|
||||
let node = self.resolve_path(&from_str)?.ok_or(FsError::NotFound)?;
|
||||
|
||||
if node.is_dir {
|
||||
return Err(FsError::NotImplemented);
|
||||
@@ -991,9 +982,14 @@ impl DavFileSystem for SanctumFs {
|
||||
)
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
|
||||
let (new_ct, new_nonce, new_tag) =
|
||||
encrypt_chunk(&self.dek, dest_node.id, idx, &plaintext, self.format_version)
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
let (new_ct, new_nonce, new_tag) = encrypt_chunk(
|
||||
&self.dek,
|
||||
dest_node.id,
|
||||
idx,
|
||||
&plaintext,
|
||||
self.format_version,
|
||||
)
|
||||
.map_err(|_| FsError::GeneralFailure)?;
|
||||
|
||||
self.db
|
||||
.write_chunk(dest_node.id, idx, &new_nonce, &new_tag, &new_ct)
|
||||
@@ -1025,7 +1021,9 @@ impl DavFileSystem for SanctumFs {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crypto::{derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION};
|
||||
use crate::crypto::{
|
||||
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION,
|
||||
};
|
||||
use dav_server::fs::OpenOptions;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
@@ -1078,7 +1076,8 @@ mod tests {
|
||||
pub struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
pub fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("sanctum_test_{}", rand::random::<u64>()));
|
||||
let p =
|
||||
std::env::temp_dir().join(format!("sanctum_test_{}", rand::random::<u64>()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
@@ -1137,7 +1136,10 @@ mod tests {
|
||||
|
||||
// read_dir mit anti_leak = true darf Thumbs.db NICHT anzeigen
|
||||
let root_path = DavPath::new("/").unwrap();
|
||||
let mut stream = fs_shielded.read_dir(&root_path, ReadDirMeta::None).await.unwrap();
|
||||
let mut stream = fs_shielded
|
||||
.read_dir(&root_path, ReadDirMeta::None)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut names = Vec::new();
|
||||
while let Some(entry) = stream.next().await {
|
||||
let entry = entry.unwrap();
|
||||
@@ -1154,7 +1156,10 @@ mod tests {
|
||||
FORMAT_VERSION,
|
||||
false,
|
||||
);
|
||||
let mut stream_unshielded = fs_unshielded.read_dir(&root_path, ReadDirMeta::None).await.unwrap();
|
||||
let mut stream_unshielded = fs_unshielded
|
||||
.read_dir(&root_path, ReadDirMeta::None)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut names_unshielded = Vec::new();
|
||||
while let Some(entry) = stream_unshielded.next().await {
|
||||
let entry = entry.unwrap();
|
||||
@@ -1263,7 +1268,13 @@ mod tests {
|
||||
|
||||
// 1. Null-Byte im Pfad
|
||||
let null_path = DavPath::new("/bad\0file.txt");
|
||||
assert!(null_path.is_err() || fs.open(&null_path.unwrap(), OpenOptions::default()).await.is_err());
|
||||
assert!(
|
||||
null_path.is_err()
|
||||
|| fs
|
||||
.open(&null_path.unwrap(), OpenOptions::default())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(validate_path_safety("/bad\0file.txt").is_err());
|
||||
|
||||
// 2. Steuerzeichen < 0x20
|
||||
@@ -1288,7 +1299,9 @@ mod tests {
|
||||
|
||||
// 1. Schreibe 500 Bytes
|
||||
let payload = Bytes::from(vec![42u8; 500]);
|
||||
file.write_buf(Box::new(std::io::Cursor::new(payload))).await.unwrap();
|
||||
file.write_buf(Box::new(std::io::Cursor::new(payload)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 2. Expliziter Flush: muss Chunk & Dateigröße atomar persistieren
|
||||
file.flush().await.unwrap();
|
||||
|
||||
+50
-45
@@ -44,7 +44,9 @@ pub fn open_in_explorer(drive_char: char) -> Result<()> {
|
||||
Command::new("explorer.exe")
|
||||
.arg(&drive_path)
|
||||
.spawn()
|
||||
.with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", drive_path))?;
|
||||
.with_context(|| {
|
||||
format!("Konnte Windows Explorer für '{}' nicht öffnen", drive_path)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -73,15 +75,19 @@ pub fn open_in_file_manager(path: &std::path::Path) -> Result<()> {
|
||||
Command::new("explorer.exe")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.with_context(|| format!("Konnte Windows Explorer für '{}' nicht öffnen", path.display()))?;
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Konnte Windows Explorer für '{}' nicht öffnen",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new("open")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.with_context(|| format!("Konnte macOS Finder für '{}' nicht öffnen", path.display()))?;
|
||||
Command::new("open").arg(path).spawn().with_context(|| {
|
||||
format!("Konnte macOS Finder für '{}' nicht öffnen", path.display())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
@@ -89,7 +95,12 @@ pub fn open_in_file_manager(path: &std::path::Path) -> Result<()> {
|
||||
Command::new("xdg-open")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.with_context(|| format!("Konnte Dateimanager via xdg-open für '{}' nicht öffnen", path.display()))?;
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Konnte Dateimanager via xdg-open für '{}' nicht öffnen",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
@@ -129,8 +140,7 @@ pub fn notify_shell_associations_changed() {
|
||||
let _ = Command::new("update-desktop-database").spawn();
|
||||
}
|
||||
#[cfg(not(any(windows, all(unix, not(target_os = "macos")))))]
|
||||
{
|
||||
}
|
||||
{}
|
||||
}
|
||||
|
||||
/// Registriert `.sanctum`-Containerdateien im Windows Explorer für den aktuellen Benutzer bzw. unter Linux via Freedesktop.
|
||||
@@ -143,11 +153,7 @@ pub fn register_explorer_integration() -> Result<()> {
|
||||
|
||||
let reg_commands = [
|
||||
// 1. .sanctum Erweiterung mit ProgID verknüpfen
|
||||
(
|
||||
r"HKCU\Software\Classes\.sanctum",
|
||||
"",
|
||||
"Sanctum.Container",
|
||||
),
|
||||
(r"HKCU\Software\Classes\.sanctum", "", "Sanctum.Container"),
|
||||
// 2. ProgID Metadaten & Beschreibung
|
||||
(
|
||||
r"HKCU\Software\Classes\Sanctum.Container",
|
||||
@@ -205,7 +211,9 @@ pub fn register_explorer_integration() -> Result<()> {
|
||||
}
|
||||
cmd.arg("/d").arg(val_data).arg("/f");
|
||||
|
||||
let output = cmd.output().with_context(|| format!("Fehler beim Ausführen von 'reg add {key}'"))?;
|
||||
let output = cmd
|
||||
.output()
|
||||
.with_context(|| format!("Fehler beim Ausführen von 'reg add {key}'"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("Registry-Fehler beim Anlegen von {key}: {stderr}");
|
||||
@@ -246,9 +254,7 @@ pub fn unregister_explorer_integration() -> Result<()> {
|
||||
];
|
||||
|
||||
for key in keys_to_delete {
|
||||
let _ = Command::new("reg")
|
||||
.args(["delete", key, "/f"])
|
||||
.output();
|
||||
let _ = Command::new("reg").args(["delete", key, "/f"]).output();
|
||||
}
|
||||
|
||||
notify_shell_associations_changed();
|
||||
@@ -258,8 +264,11 @@ pub fn unregister_explorer_integration() -> Result<()> {
|
||||
{
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let home_path = std::path::PathBuf::from(home);
|
||||
let _ = std::fs::remove_file(home_path.join(".local/share/applications/sanctum.desktop"));
|
||||
let _ = std::fs::remove_file(home_path.join(".local/share/mime/packages/application-x-sanctum.xml"));
|
||||
let _ =
|
||||
std::fs::remove_file(home_path.join(".local/share/applications/sanctum.desktop"));
|
||||
let _ = std::fs::remove_file(
|
||||
home_path.join(".local/share/mime/packages/application-x-sanctum.xml"),
|
||||
);
|
||||
notify_shell_associations_changed();
|
||||
}
|
||||
Ok(())
|
||||
@@ -496,9 +505,9 @@ pub fn start_session_lock_monitor(
|
||||
})
|
||||
.context("Konnte Windows Session-Monitor-Thread nicht starten")?;
|
||||
|
||||
let hwnd = hwnd_rx
|
||||
.recv()
|
||||
.map_err(|e| anyhow::anyhow!("Session-Monitor-Thread initialisierte nicht rechtzeitig: {e}"))?;
|
||||
let hwnd = hwnd_rx.recv().map_err(|e| {
|
||||
anyhow::anyhow!("Session-Monitor-Thread initialisierte nicht rechtzeitig: {e}")
|
||||
})?;
|
||||
|
||||
if hwnd == 0 {
|
||||
bail!("Win32-Nachrichtenfenster für Session-Lock konnte nicht erstellt werden");
|
||||
@@ -522,7 +531,8 @@ pub fn start_session_lock_monitor(
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(windows)]
|
||||
static CONSOLE_CTRL_TX: std::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>> = std::sync::Mutex::new(None);
|
||||
static CONSOLE_CTRL_TX: std::sync::Mutex<Option<tokio::sync::mpsc::Sender<()>>> =
|
||||
std::sync::Mutex::new(None);
|
||||
#[cfg(windows)]
|
||||
static CONSOLE_CTRL_DRIVE: std::sync::Mutex<Option<char>> = std::sync::Mutex::new(None);
|
||||
|
||||
@@ -550,11 +560,7 @@ extern "system" {
|
||||
dwFlags: u32,
|
||||
) -> u32;
|
||||
|
||||
fn WNetCancelConnection2W(
|
||||
lpName: *const u16,
|
||||
dwFlags: u32,
|
||||
fForce: i32,
|
||||
) -> u32;
|
||||
fn WNetCancelConnection2W(lpName: *const u16, dwFlags: u32, fForce: i32) -> u32;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -571,10 +577,8 @@ unsafe extern "system" fn console_ctrl_routine(ctrl_type: u32) -> i32 {
|
||||
if let Ok(guard) = CONSOLE_CTRL_DRIVE.lock() {
|
||||
if let Some(dl) = *guard {
|
||||
let drive_str = format!("{}:", dl.to_ascii_uppercase());
|
||||
let wide_drive: Vec<u16> = drive_str
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let wide_drive: Vec<u16> =
|
||||
drive_str.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
unsafe {
|
||||
WNetCancelConnection2W(wide_drive.as_ptr(), 0, 1);
|
||||
}
|
||||
@@ -724,9 +728,15 @@ pub fn mount_drive_wnet(drive_letter: char, port: u16, session_token: &str) -> R
|
||||
let drive_str = format!("{}:", drive_letter.to_ascii_uppercase());
|
||||
let mut local_name: Vec<u16> = drive_str.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let remote_url = format!("http://127.0.0.1:{}/", port);
|
||||
let mut remote_name: Vec<u16> = remote_url.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let mut remote_name: Vec<u16> = remote_url
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let username: Vec<u16> = "sanctum".encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let password: Vec<u16> = session_token.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let password: Vec<u16> = session_token
|
||||
.encode_utf16()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
let nr = NETRESOURCEW {
|
||||
dwScope: 0,
|
||||
@@ -750,18 +760,14 @@ pub fn mount_drive_wnet(drive_letter: char, port: u16, session_token: &str) -> R
|
||||
|
||||
// PRR-01: Selbstreparatur bei verwaister Zuordnung nach unsauberem Vorläufer (Systemfehler 85 / 1202)
|
||||
if res == 85 || res == 1202 {
|
||||
debug!("Verwaiste Zuordnung für {} entdeckt — führe automatische Bereinigung durch...", drive_str);
|
||||
debug!(
|
||||
"Verwaiste Zuordnung für {} entdeckt — führe automatische Bereinigung durch...",
|
||||
drive_str
|
||||
);
|
||||
unsafe {
|
||||
WNetCancelConnection2W(local_name.as_ptr(), 0, 1);
|
||||
}
|
||||
res = unsafe {
|
||||
WNetAddConnection2W(
|
||||
&nr,
|
||||
password.as_ptr(),
|
||||
username.as_ptr(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
res = unsafe { WNetAddConnection2W(&nr, password.as_ptr(), username.as_ptr(), 0) };
|
||||
}
|
||||
|
||||
if res != 0 {
|
||||
@@ -849,4 +855,3 @@ mod tests {
|
||||
drop(monitor);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user