synor/crates/synor-crypto/src/mnemonic.rs
2026-01-08 05:22:24 +05:30

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());
}
}