200 lines
6.1 KiB
Rust
200 lines
6.1 KiB
Rust
//! BIP-39 mnemonic support for Synor wallet key generation.
|
|
//!
|
|
//! Provides secure mnemonic phrase generation and seed derivation.
|
|
|
|
// The `inner` field warning is a false positive from the ZeroizeOnDrop derive
|
|
#![allow(unused_assignments)]
|
|
|
|
use bip39::{Language, Mnemonic as Bip39Mnemonic, MnemonicType, Seed};
|
|
use thiserror::Error;
|
|
use zeroize::ZeroizeOnDrop;
|
|
|
|
/// A BIP-39 mnemonic phrase for key derivation.
|
|
#[derive(Clone, ZeroizeOnDrop)]
|
|
pub struct Mnemonic {
|
|
#[zeroize(skip)]
|
|
inner: Bip39Mnemonic,
|
|
}
|
|
|
|
impl Mnemonic {
|
|
/// Generates a new random mnemonic with the specified word count.
|
|
///
|
|
/// Supported word counts: 12, 15, 18, 21, 24
|
|
pub fn generate(word_count: usize) -> Result<Self, MnemonicError> {
|
|
let mnemonic_type = match word_count {
|
|
12 => MnemonicType::Words12,
|
|
15 => MnemonicType::Words15,
|
|
18 => MnemonicType::Words18,
|
|
21 => MnemonicType::Words21,
|
|
24 => MnemonicType::Words24,
|
|
_ => return Err(MnemonicError::InvalidWordCount(word_count)),
|
|
};
|
|
let inner = Bip39Mnemonic::new(mnemonic_type, Language::English);
|
|
Ok(Mnemonic { inner })
|
|
}
|
|
|
|
/// Creates a mnemonic from a phrase string.
|
|
pub fn from_phrase(phrase: &str) -> Result<Self, MnemonicError> {
|
|
let inner = Bip39Mnemonic::from_phrase(phrase, Language::English)
|
|
.map_err(|e| MnemonicError::InvalidPhrase(format!("{}", e)))?;
|
|
Ok(Mnemonic { inner })
|
|
}
|
|
|
|
/// Returns the mnemonic phrase as a string.
|
|
pub fn phrase(&self) -> &str {
|
|
self.inner.phrase()
|
|
}
|
|
|
|
/// Returns the mnemonic words as a vector.
|
|
pub fn words(&self) -> Vec<&str> {
|
|
self.inner.phrase().split_whitespace().collect()
|
|
}
|
|
|
|
/// Derives a 64-byte seed from the mnemonic with an optional passphrase.
|
|
pub fn to_seed(&self, passphrase: &str) -> [u8; 64] {
|
|
let seed = Seed::new(&self.inner, passphrase);
|
|
let mut result = [0u8; 64];
|
|
result.copy_from_slice(seed.as_bytes());
|
|
result
|
|
}
|
|
|
|
/// Returns the entropy bytes.
|
|
pub fn entropy(&self) -> &[u8] {
|
|
self.inner.entropy()
|
|
}
|
|
|
|
/// Creates a mnemonic from entropy bytes.
|
|
pub fn from_entropy(entropy: &[u8]) -> Result<Self, MnemonicError> {
|
|
let inner = Bip39Mnemonic::from_entropy(entropy, Language::English)
|
|
.map_err(|e| MnemonicError::InvalidEntropy(format!("{}", e)))?;
|
|
Ok(Mnemonic { inner })
|
|
}
|
|
|
|
/// Validates that a phrase is a valid BIP-39 mnemonic.
|
|
pub fn validate(phrase: &str) -> bool {
|
|
Bip39Mnemonic::validate(phrase, Language::English).is_ok()
|
|
}
|
|
|
|
/// Returns the word count.
|
|
pub fn word_count(&self) -> usize {
|
|
self.inner.phrase().split_whitespace().count()
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for Mnemonic {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("Mnemonic")
|
|
.field("word_count", &self.word_count())
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for Mnemonic {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
// Only show first and last word for security
|
|
let words: Vec<&str> = self.words();
|
|
if words.len() >= 2 {
|
|
write!(
|
|
f,
|
|
"{} ... {} ({} words)",
|
|
words[0],
|
|
words[words.len() - 1],
|
|
words.len()
|
|
)
|
|
} else {
|
|
write!(f, "[invalid mnemonic]")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Errors related to mnemonics.
|
|
#[derive(Debug, Error)]
|
|
pub enum MnemonicError {
|
|
#[error("Invalid word count: {0}. Must be 12, 15, 18, 21, or 24")]
|
|
InvalidWordCount(usize),
|
|
|
|
#[error("Invalid mnemonic phrase: {0}")]
|
|
InvalidPhrase(String),
|
|
|
|
#[error("Invalid entropy: {0}")]
|
|
InvalidEntropy(String),
|
|
|
|
#[error("Checksum validation failed")]
|
|
ChecksumFailed,
|
|
}
|
|
|
|
/// Suggests completions for a partial word from the BIP-39 wordlist.
|
|
pub fn suggest_word(partial: &str) -> Vec<&'static str> {
|
|
let wl = Language::English.wordlist();
|
|
wl.get_words_by_prefix(partial)
|
|
.iter()
|
|
.take(10)
|
|
.copied()
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_generate_mnemonic() {
|
|
let mnemonic = Mnemonic::generate(12).unwrap();
|
|
assert_eq!(mnemonic.word_count(), 12);
|
|
|
|
let mnemonic = Mnemonic::generate(24).unwrap();
|
|
assert_eq!(mnemonic.word_count(), 24);
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_word_count() {
|
|
assert!(Mnemonic::generate(11).is_err());
|
|
assert!(Mnemonic::generate(25).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_from_phrase() {
|
|
let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
|
let mnemonic = Mnemonic::from_phrase(phrase).unwrap();
|
|
assert_eq!(mnemonic.phrase(), phrase);
|
|
}
|
|
|
|
#[test]
|
|
fn test_seed_derivation() {
|
|
let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
|
let mnemonic = Mnemonic::from_phrase(phrase).unwrap();
|
|
|
|
let seed1 = mnemonic.to_seed("");
|
|
let seed2 = mnemonic.to_seed("password");
|
|
|
|
// Same mnemonic with different passphrases should produce different seeds
|
|
assert_ne!(seed1, seed2);
|
|
|
|
// Same mnemonic and passphrase should produce same seed
|
|
let seed3 = mnemonic.to_seed("");
|
|
assert_eq!(seed1, seed3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_phrase() {
|
|
assert!(Mnemonic::validate(
|
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
));
|
|
assert!(!Mnemonic::validate("invalid phrase here"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_suggest_word() {
|
|
let suggestions = suggest_word("aban");
|
|
assert!(suggestions.contains(&"abandon"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_entropy_roundtrip() {
|
|
let mnemonic = Mnemonic::generate(12).unwrap();
|
|
let entropy = mnemonic.entropy().to_vec();
|
|
|
|
let recovered = Mnemonic::from_entropy(&entropy).unwrap();
|
|
assert_eq!(mnemonic.phrase(), recovered.phrase());
|
|
}
|
|
}
|