#![cfg_attr(docsrs, feature(doc_auto_cfg))] #![doc = include_str!("../README.md")] #![deny(missing_docs)] #![cfg_attr(not(feature = "std"), no_std)] use core::{ops::Deref, fmt}; use std_shims::{ sync::LazyLock, vec, vec::Vec, string::{String, ToString}, collections::HashMap, }; use zeroize::{Zeroize, Zeroizing}; use rand_core::{RngCore, CryptoRng}; use curve25519_dalek::scalar::Scalar; #[cfg(test)] mod tests; // The amount of words in a seed without a checksum. const SEED_LENGTH: usize = 24; // The amount of words in a seed with a checksum. const SEED_LENGTH_WITH_CHECKSUM: usize = 25; /// An error when working with a seed. #[derive(Clone, Copy, PartialEq, Eq, Debug)] #[cfg_attr(feature = "std", derive(thiserror::Error))] pub enum SeedError { #[cfg_attr(feature = "std", error("invalid seed"))] /// The seed was invalid. InvalidSeed, /// The checksum did not match the data. #[cfg_attr(feature = "std", error("invalid checksum"))] InvalidChecksum, /// The deprecated English language option was used with a checksum. /// /// The deprecated English language option did not include a checksum. #[cfg_attr(feature = "std", error("deprecated English language option included a checksum"))] DeprecatedEnglishWithChecksum, } /// Language options. #[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, Zeroize)] pub enum Language { /// Chinese language option. Chinese, /// English language option. English, /// Dutch language option. Dutch, /// French language option. French, /// Spanish language option. Spanish, /// German language option. German, /// Italian language option. Italian, /// Portuguese language option. Portuguese, /// Japanese language option. Japanese, /// Russian language option. Russian, /// Esperanto language option. Esperanto, /// Lojban language option. Lojban, /// The original, and deprecated, English language. DeprecatedEnglish, } fn trim(word: &str, len: usize) -> Zeroizing { Zeroizing::new(word.chars().take(len).collect()) } struct WordList { word_list: &'static [&'static str], word_map: HashMap<&'static str, usize>, trimmed_word_map: HashMap, unique_prefix_length: usize, } impl WordList { fn new(word_list: &'static [&'static str], prefix_length: usize) -> WordList { let mut lang = WordList { word_list, word_map: HashMap::new(), trimmed_word_map: HashMap::new(), unique_prefix_length: prefix_length, }; for (i, word) in lang.word_list.iter().enumerate() { lang.word_map.insert(word, i); lang.trimmed_word_map.insert(trim(word, lang.unique_prefix_length).deref().clone(), i); } lang } } static LANGUAGES: LazyLock> = LazyLock::new(|| { HashMap::from([ (Language::Chinese, WordList::new(include!("./words/zh.rs"), 1)), (Language::English, WordList::new(include!("./words/en.rs"), 3)), (Language::Dutch, WordList::new(include!("./words/nl.rs"), 4)), (Language::French, WordList::new(include!("./words/fr.rs"), 4)), (Language::Spanish, WordList::new(include!("./words/es.rs"), 4)), (Language::German, WordList::new(include!("./words/de.rs"), 4)), (Language::Italian, WordList::new(include!("./words/it.rs"), 4)), (Language::Portuguese, WordList::new(include!("./words/pt.rs"), 4)), (Language::Japanese, WordList::new(include!("./words/ja.rs"), 3)), (Language::Russian, WordList::new(include!("./words/ru.rs"), 4)), (Language::Esperanto, WordList::new(include!("./words/eo.rs"), 4)), (Language::Lojban, WordList::new(include!("./words/jbo.rs"), 4)), (Language::DeprecatedEnglish, WordList::new(include!("./words/ang.rs"), 4)), ]) }); fn checksum_index(words: &[Zeroizing], lang: &WordList) -> usize { let mut trimmed_words = Zeroizing::new(String::new()); for w in words { *trimmed_words += &trim(w, lang.unique_prefix_length); } const fn crc32_table() -> [u32; 256] { let poly = 0xedb88320u32; let mut res = [0; 256]; let mut i = 0; while i < 256 { let mut entry = i; let mut b = 0; while b < 8 { let trigger = entry & 1; entry >>= 1; if trigger == 1 { entry ^= poly; } b += 1; } res[i as usize] = entry; i += 1; } res } const CRC32_TABLE: [u32; 256] = crc32_table(); let trimmed_words = trimmed_words.as_bytes(); let mut checksum = u32::MAX; for i in 0 .. trimmed_words.len() { checksum = CRC32_TABLE[usize::from(u8::try_from(checksum % 256).unwrap() ^ trimmed_words[i])] ^ (checksum >> 8); } usize::try_from(!checksum).unwrap() % words.len() } // Convert a private key to a seed #[allow(clippy::needless_pass_by_value)] fn key_to_seed(lang: Language, key: Zeroizing) -> Seed { let bytes = Zeroizing::new(key.to_bytes()); // get the language words let words = &LANGUAGES[&lang].word_list; let list_len = u64::try_from(words.len()).unwrap(); // To store the found words & add the checksum word later. let mut seed = Vec::with_capacity(25); // convert to words // 4 bytes -> 3 words. 8 digits base 16 -> 3 digits base 1626 let mut segment = [0; 4]; let mut indices = [0; 4]; for i in 0 .. 8 { // convert first 4 byte to u32 & get the word indices let start = i * 4; // convert 4 byte to u32 segment.copy_from_slice(&bytes[start .. (start + 4)]); // Actually convert to a u64 so we can add without overflowing indices[0] = u64::from(u32::from_le_bytes(segment)); indices[1] = indices[0]; indices[0] /= list_len; indices[2] = indices[0] + indices[1]; indices[0] /= list_len; indices[3] = indices[0] + indices[2]; // append words to seed for i in indices.iter().skip(1) { let word = usize::try_from(i % list_len).unwrap(); seed.push(Zeroizing::new(words[word].to_string())); } } segment.zeroize(); indices.zeroize(); // create a checksum word for all languages except old english if lang != Language::DeprecatedEnglish { let checksum = seed[checksum_index(&seed, &LANGUAGES[&lang])].clone(); seed.push(checksum); } let mut res = Zeroizing::new(String::new()); for (i, word) in seed.iter().enumerate() { if i != 0 { *res += " "; } *res += word; } Seed(lang, res) } // Convert a seed to bytes fn seed_to_bytes(lang: Language, words: &str) -> Result, SeedError> { // get seed words let words = words.split_whitespace().map(|w| Zeroizing::new(w.to_string())).collect::>(); if (words.len() != SEED_LENGTH) && (words.len() != SEED_LENGTH_WITH_CHECKSUM) { panic!("invalid seed passed to seed_to_bytes"); } let has_checksum = words.len() == SEED_LENGTH_WITH_CHECKSUM; if has_checksum && lang == Language::DeprecatedEnglish { Err(SeedError::DeprecatedEnglishWithChecksum)?; } // Validate words are in the language word list let lang_word_list: &WordList = &LANGUAGES[&lang]; let matched_indices = (|| { let has_checksum = words.len() == SEED_LENGTH_WITH_CHECKSUM; let mut matched_indices = Zeroizing::new(vec![]); // Iterate through all the words and see if they're all present for word in &words { let trimmed = trim(word, lang_word_list.unique_prefix_length); let word = if has_checksum { &trimmed } else { word }; if let Some(index) = if has_checksum { lang_word_list.trimmed_word_map.get(word.deref()) } else { lang_word_list.word_map.get(&word.as_str()) } { matched_indices.push(*index); } else { Err(SeedError::InvalidSeed)?; } } if has_checksum { // exclude the last word when calculating a checksum. let last_word = words.last().unwrap().clone(); let checksum = words[checksum_index(&words[.. words.len() - 1], lang_word_list)].clone(); // check the trimmed checksum and trimmed last word line up if trim(&checksum, lang_word_list.unique_prefix_length) != trim(&last_word, lang_word_list.unique_prefix_length) { Err(SeedError::InvalidChecksum)?; } } Ok(matched_indices) })()?; // convert to bytes let mut res = Zeroizing::new([0; 32]); let mut indices = Zeroizing::new([0; 4]); for i in 0 .. 8 { // read 3 indices at a time let i3 = i * 3; indices[1] = matched_indices[i3]; indices[2] = matched_indices[i3 + 1]; indices[3] = matched_indices[i3 + 2]; let inner = |i| { let mut base = (lang_word_list.word_list.len() - indices[i] + indices[i + 1]) % lang_word_list.word_list.len(); // Shift the index over for _ in 0 .. i { base *= lang_word_list.word_list.len(); } base }; // set the last index indices[0] = indices[1] + inner(1) + inner(2); if (indices[0] % lang_word_list.word_list.len()) != indices[1] { Err(SeedError::InvalidSeed)?; } let pos = i * 4; let mut bytes = u32::try_from(indices[0]).unwrap().to_le_bytes(); res[pos .. (pos + 4)].copy_from_slice(&bytes); bytes.zeroize(); } Ok(res) } /// A Monero seed. #[derive(Clone, PartialEq, Eq, Zeroize)] pub struct Seed(Language, Zeroizing); impl fmt::Debug for Seed { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.debug_struct("Seed").finish_non_exhaustive() } } impl Seed { /// Create a new seed. pub fn new(rng: &mut R, lang: Language) -> Seed { let mut scalar_bytes = Zeroizing::new([0; 64]); rng.fill_bytes(scalar_bytes.as_mut()); key_to_seed(lang, Zeroizing::new(Scalar::from_bytes_mod_order_wide(scalar_bytes.deref()))) } /// Parse a seed from a string. #[allow(clippy::needless_pass_by_value)] pub fn from_string(lang: Language, words: Zeroizing) -> Result { let entropy = seed_to_bytes(lang, &words)?; // Make sure this is a valid scalar let scalar = Scalar::from_canonical_bytes(*entropy); if scalar.is_none().into() { Err(SeedError::InvalidSeed)?; } let mut scalar = scalar.unwrap(); scalar.zeroize(); // Call from_entropy so a trimmed seed becomes a full seed Ok(Self::from_entropy(lang, entropy).unwrap()) } /// Create a seed from entropy. #[allow(clippy::needless_pass_by_value)] pub fn from_entropy(lang: Language, entropy: Zeroizing<[u8; 32]>) -> Option { Option::from(Scalar::from_canonical_bytes(*entropy)) .map(|scalar| key_to_seed(lang, Zeroizing::new(scalar))) } /// Convert a seed to a string. pub fn to_string(&self) -> Zeroizing { self.1.clone() } /// Return the entropy underlying this seed. pub fn entropy(&self) -> Zeroizing<[u8; 32]> { seed_to_bytes(self.0, &self.1).unwrap() } }