feat(opsec-ux): implement HF-01 to HF-04 and VFS carrier protection
- HF-01: eliminate visual leaks between decoy and hidden vaults during mount - HF-02: add secure interactive BIP-39 recovery prompt avoiding shell history - HF-03: implement BIP-39 normalization, word index error pinpointing, and Levenshtein typo suggestions - HF-04: add --stealth mode for silent mounting in high-risk environments - VFS: enforce write, truncate, delete, rename, and directory removal protection for carrier node in decoy vault
This commit is contained in:
+139
-9
@@ -320,16 +320,121 @@ pub fn dek_to_mnemonic(dek: &[u8; 32]) -> Result<String> {
|
||||
Ok(mnemonic.to_string())
|
||||
}
|
||||
|
||||
/// Dekodiert eine 24-Wort BIP-39 Notfall-Wiederherstellungsphrase zurück in den 32-Byte DEK.
|
||||
/// Validiert dabei Wörter und die integrierte BIP-39 Prüfsumme.
|
||||
pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
|
||||
let cleaned = phrase
|
||||
.split_whitespace()
|
||||
.collect::<Vec<&str>>()
|
||||
.join(" ");
|
||||
pub fn levenshtein_distance(a: &str, b: &str) -> usize {
|
||||
let a_chars: Vec<char> = a.chars().collect();
|
||||
let b_chars: Vec<char> = b.chars().collect();
|
||||
let (m, n) = (a_chars.len(), b_chars.len());
|
||||
|
||||
let mnemonic = bip39::Mnemonic::parse_normalized(&cleaned)
|
||||
.map_err(|e| anyhow::anyhow!("Ungültige BIP-39 Notfallphrase (Wortfehler oder ungültige Prüfsumme): {e}"))?;
|
||||
let mut dp = vec![vec![0usize; n + 1]; m + 1];
|
||||
for i in 0..=m {
|
||||
dp[i][0] = i;
|
||||
}
|
||||
for j in 0..=n {
|
||||
dp[0][j] = j;
|
||||
}
|
||||
|
||||
for i in 1..=m {
|
||||
for j in 1..=n {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
dp[m][n]
|
||||
}
|
||||
|
||||
/// Sucht den besten Korrektur-Kandidaten aus der BIP-39 Wortliste für ein fehlerhaftes Wort.
|
||||
pub fn suggest_bip39_word(word: &str) -> Option<&'static str> {
|
||||
let word_list = bip39::Language::English.word_list();
|
||||
let mut best_match = None;
|
||||
let mut min_dist = usize::MAX;
|
||||
|
||||
for &valid in word_list {
|
||||
let dist = levenshtein_distance(word, valid);
|
||||
if dist < min_dist {
|
||||
min_dist = dist;
|
||||
best_match = Some(valid);
|
||||
}
|
||||
}
|
||||
|
||||
// Vorschlag nur zurückgeben, wenn Distanz klein genug ist (z. B. <= 2)
|
||||
if min_dist <= 2 {
|
||||
best_match
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalisiert eine eingegebene BIP-39 Phrase:
|
||||
/// - Wandelt in Kleinbuchstaben um (to_lowercase)
|
||||
/// - Ersetzt Satzzeichen und Trennzeichen durch Leerzeichen
|
||||
/// - Bereinigt überflüssige Leerzeichen (Whitespace-Collapsing)
|
||||
pub fn normalize_mnemonic_phrase(phrase: &str) -> Vec<String> {
|
||||
let cleaned: String = phrase
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphabetic() {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
' '
|
||||
}
|
||||
})
|
||||
.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.
|
||||
/// Validiert dabei Wörter und die integrierte BIP-39 Prüfsumme mit präziser Fehlerdiagnose.
|
||||
pub fn mnemonic_to_dek(phrase: &str) -> Result<Zeroizing<[u8; 32]>> {
|
||||
let words = normalize_mnemonic_phrase(phrase);
|
||||
|
||||
if words.is_empty() {
|
||||
bail!("Die Notfallphrase darf nicht leer sein.");
|
||||
}
|
||||
|
||||
if words.len() != 24 {
|
||||
bail!(
|
||||
"Ungültige Wortanzahl in der Notfallphrase: Erwartet werden genau 24 Wörter, eingegeben wurden jedoch {} Wörter.",
|
||||
words.len()
|
||||
);
|
||||
}
|
||||
|
||||
let word_list = bip39::Language::English.word_list();
|
||||
let mut invalid_words = Vec::new();
|
||||
|
||||
for (idx, word) in words.iter().enumerate() {
|
||||
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)
|
||||
} else {
|
||||
format!("Wort #{} '{}' ist ungültig (nicht im BIP-39 Wörterbuch)", idx + 1, word)
|
||||
};
|
||||
invalid_words.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
if !invalid_words.is_empty() {
|
||||
bail!(
|
||||
"Ungültige Wörter in der Notfallphrase festgestellt:\n • {}",
|
||||
invalid_words.join("\n • ")
|
||||
);
|
||||
}
|
||||
|
||||
let normalized_phrase = words.join(" ");
|
||||
let mnemonic = bip39::Mnemonic::parse_normalized(&normalized_phrase)
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Alle 24 Wörter sind gültige BIP-39 Wörter, aber die Prüfsumme (Checksumme) ist ungültig. \
|
||||
Bitte prüfen Sie die exakte Reihenfolge der Wörter oder das 24. Wort."
|
||||
)
|
||||
})?;
|
||||
|
||||
let entropy = mnemonic.to_entropy();
|
||||
if entropy.len() != 32 {
|
||||
@@ -625,6 +730,31 @@ mod tests {
|
||||
assert!(mnemonic_to_dek(&corrupted_phrase).is_err(), "Checksum check must fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bip39_advanced_normalisation_and_suggestions() {
|
||||
let dek = generate_dek();
|
||||
let mnemonic_str = dek_to_mnemonic(&dek).unwrap();
|
||||
let words: Vec<&str> = mnemonic_str.split_whitespace().collect();
|
||||
|
||||
// 1. Großbuchstaben-Normalisierung (z.B. Smartphone Auto-Capitalization)
|
||||
let uppercase_phrase = mnemonic_str.to_uppercase();
|
||||
let recovered = mnemonic_to_dek(&uppercase_phrase).expect("Recover uppercase");
|
||||
assert_eq!(*dek, *recovered);
|
||||
|
||||
// 2. Satzzeichen & Trennzeichen (z.B. Kommas oder Punkte zwischen Wörtern)
|
||||
let punctuated = format!("{}.", words.join(", "));
|
||||
let recovered_punct = mnemonic_to_dek(&punctuated).expect("Recover punctuated");
|
||||
assert_eq!(*dek, *recovered_punct);
|
||||
|
||||
// 3. Typo-Vorschlag (Levenshtein)
|
||||
let mut typo_words = words.clone();
|
||||
typo_words[5] = "abondon"; // Typo for abandon
|
||||
let typo_phrase = typo_words.join(" ");
|
||||
let err = mnemonic_to_dek(&typo_phrase).unwrap_err().to_string();
|
||||
assert!(err.contains("Wort #6 'abondon' ist ungültig"));
|
||||
assert!(err.contains("Meinten Sie 'abandon'?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hidden_node_name_encryption_and_dummy_slot() {
|
||||
let dek = generate_dek();
|
||||
|
||||
Reference in New Issue
Block a user