diff --git a/coins/monero/src/tests/seed.rs b/coins/monero/src/tests/seed.rs index 646441184..878293a0c 100644 --- a/coins/monero/src/tests/seed.rs +++ b/coins/monero/src/tests/seed.rs @@ -137,6 +137,53 @@ fn test_classic_seed() { spend: "647f4765b66b636ff07170ab6280a9a6804dfbaf19db2ad37d23be024a18730b".into(), view: "045da65316a906a8c30046053119c18020b07a7a3a6ef5c01ab2a8755416bd02".into(), }, + // The following seeds require the language specification in order to calculate + // a single valid checksum + Vector { + language: classic::Language::Spanish, + seed: "pluma laico atraer pintor peor cerca balde buscar \ + lancha batir nulo reloj resto gemelo nevera poder columna gol \ + oveja latir amplio bolero feliz fuerza nevera" + .into(), + spend: "30303983fc8d215dd020cc6b8223793318d55c466a86e4390954f373fdc7200a".into(), + view: "97c649143f3c147ba59aa5506cc09c7992c5c219bb26964442142bf97980800e".into(), + }, + Vector { + language: classic::Language::Spanish, + seed: "pluma pluma pluma pluma pluma pluma pluma pluma \ + pluma pluma pluma pluma pluma pluma pluma pluma \ + pluma pluma pluma pluma pluma pluma pluma pluma pluma" + .into(), + spend: "b4050000b4050000b4050000b4050000b4050000b4050000b4050000b4050000".into(), + view: "d73534f7912b395eb70ef911791a2814eb6df7ce56528eaaa83ff2b72d9f5e0f".into(), + }, + Vector { + language: classic::Language::English, + seed: "plus plus plus plus plus plus plus plus \ + plus plus plus plus plus plus plus plus \ + plus plus plus plus plus plus plus plus plus" + .into(), + spend: "3b0400003b0400003b0400003b0400003b0400003b0400003b0400003b040000".into(), + view: "43a8a7715eed11eff145a2024ddcc39740255156da7bbd736ee66a0838053a02".into(), + }, + Vector { + language: classic::Language::Spanish, + seed: "audio audio audio audio audio audio audio audio \ + audio audio audio audio audio audio audio audio \ + audio audio audio audio audio audio audio audio audio" + .into(), + spend: "ba000000ba000000ba000000ba000000ba000000ba000000ba000000ba000000".into(), + view: "1437256da2c85d029b293d8c6b1d625d9374969301869b12f37186e3f906c708".into(), + }, + Vector { + language: classic::Language::English, + seed: "audio audio audio audio audio audio audio audio \ + audio audio audio audio audio audio audio audio \ + audio audio audio audio audio audio audio audio audio" + .into(), + spend: "7900000079000000790000007900000079000000790000007900000079000000".into(), + view: "20bec797ab96780ae6a045dd816676ca7ed1d7c6773f7022d03ad234b581d600".into(), + }, ]; for vector in vectors { @@ -150,15 +197,15 @@ fn test_classic_seed() { // Test against Monero { - let seed = Seed::from_string(Zeroizing::new(vector.seed.clone())).unwrap(); + println!("{}. language: {:?}, seed: {}", line!(), vector.language, vector.seed.clone()); + let seed = + Seed::from_string(SeedType::Classic(vector.language), Zeroizing::new(vector.seed.clone())) + .unwrap(); let trim = trim_seed(&vector.seed); - println!( - "{}. seed: {}, entropy: {:?}, trim: {trim}", - line!(), - *seed.to_string(), - *seed.entropy() + assert_eq!( + seed, + Seed::from_string(SeedType::Classic(vector.language), Zeroizing::new(trim)).unwrap() ); - assert_eq!(seed, Seed::from_string(Zeroizing::new(trim)).unwrap()); let spend: [u8; 32] = hex::decode(vector.spend).unwrap().try_into().unwrap(); // For classical seeds, Monero directly uses the entropy as a spend key @@ -184,19 +231,20 @@ fn test_classic_seed() { // Test against ourselves { let seed = Seed::new(&mut OsRng, SeedType::Classic(vector.language)); + println!("{}. seed: {}", line!(), *seed.to_string()); let trim = trim_seed(&seed.to_string()); - println!( - "{}. seed: {}, entropy: {:?}, trim: {trim}", - line!(), - *seed.to_string(), - *seed.entropy() + assert_eq!( + seed, + Seed::from_string(SeedType::Classic(vector.language), Zeroizing::new(trim)).unwrap() ); - assert_eq!(seed, Seed::from_string(Zeroizing::new(trim)).unwrap()); assert_eq!( seed, Seed::from_entropy(SeedType::Classic(vector.language), seed.entropy(), None).unwrap() ); - assert_eq!(seed, Seed::from_string(seed.to_string()).unwrap()); + assert_eq!( + seed, + Seed::from_string(SeedType::Classic(vector.language), seed.to_string()).unwrap() + ); } } } @@ -309,6 +357,18 @@ fn test_polyseed() { has_prefix: false, has_accent: false, }, + // The following seed requires the language specification in order to calculate + // a single valid checksum + Vector { + language: polyseed::Language::Spanish, + seed: "impo sort usua cabi venu nobl oliv clim \ + cont barr marc auto prod vaca torn fati" + .into(), + entropy: "dbfce25fe09b68a340e01c62417eeef43ad51800000000000000000000000000".into(), + birthday: 1701511650, + has_prefix: true, + has_accent: true, + }, ]; for vector in vectors { @@ -350,31 +410,32 @@ fn test_polyseed() { }; // String -> Seed - let seed = Seed::from_string(Zeroizing::new(vector.seed.clone())).unwrap(); + println!("{}. language: {:?}, seed: {}", line!(), vector.language, vector.seed.clone()); + let seed = + Seed::from_string(SeedType::Polyseed(vector.language), Zeroizing::new(vector.seed.clone())) + .unwrap(); let trim = trim_seed(&vector.seed); let add_whitespace = add_whitespace(vector.seed.clone()); let seed_without_accents = seed_without_accents(&vector.seed); - println!( - "{}. seed: {}, entropy: {:?}, trim: {}, add_whitespace: {}, seed_without_accents: {}", - line!(), - *seed.to_string(), - *seed.entropy(), - trim, - add_whitespace, - seed_without_accents, - ); // Make sure a version with added whitespace still works - let whitespaced_seed = Seed::from_string(Zeroizing::new(add_whitespace)).unwrap(); + let whitespaced_seed = + Seed::from_string(SeedType::Polyseed(vector.language), Zeroizing::new(add_whitespace)) + .unwrap(); assert_eq!(seed, whitespaced_seed); // Check trimmed versions works if vector.has_prefix { - let trimmed_seed = Seed::from_string(Zeroizing::new(trim)).unwrap(); + let trimmed_seed = + Seed::from_string(SeedType::Polyseed(vector.language), Zeroizing::new(trim)).unwrap(); assert_eq!(seed, trimmed_seed); } // Check versions without accents work if vector.has_accent { - let seed_without_accents = Seed::from_string(Zeroizing::new(seed_without_accents)).unwrap(); + let seed_without_accents = Seed::from_string( + SeedType::Polyseed(vector.language), + Zeroizing::new(seed_without_accents), + ) + .unwrap(); assert_eq!(seed, seed_without_accents); } @@ -391,8 +452,11 @@ fn test_polyseed() { // Check against ourselves { let seed = Seed::new(&mut OsRng, SeedType::Polyseed(vector.language)); - println!("{}. seed: {}, key: {:?}", line!(), *seed.to_string(), *seed.key()); - assert_eq!(seed, Seed::from_string(seed.to_string()).unwrap()); + println!("{}. seed: {}", line!(), *seed.to_string()); + assert_eq!( + seed, + Seed::from_string(SeedType::Polyseed(vector.language), seed.to_string()).unwrap() + ); assert_eq!( seed, Seed::from_entropy( diff --git a/coins/monero/src/wallet/seed/classic.rs b/coins/monero/src/wallet/seed/classic.rs index 80c11ab32..afe8679a8 100644 --- a/coins/monero/src/wallet/seed/classic.rs +++ b/coins/monero/src/wallet/seed/classic.rs @@ -16,7 +16,7 @@ use crate::{random_scalar, wallet::seed::SeedError}; pub(crate) const CLASSIC_SEED_LENGTH: usize = 24; pub(crate) const CLASSIC_SEED_LENGTH_WITH_CHECKSUM: usize = 25; -#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, Zeroize)] pub enum Language { Chinese, English, @@ -184,63 +184,58 @@ fn key_to_seed(lang: Language, key: Zeroizing) -> ClassicSeed { } *res += word; } - ClassicSeed(res) + ClassicSeed(lang, res) } // Convert a seed to bytes -pub(crate) fn seed_to_bytes(words: &str) -> Result<(Language, Zeroizing<[u8; 32]>), SeedError> { +pub(crate) 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() != CLASSIC_SEED_LENGTH) && (words.len() != CLASSIC_SEED_LENGTH_WITH_CHECKSUM) { panic!("invalid seed passed to seed_to_bytes"); } - // find the language - let (matched_indices, lang_name, lang) = (|| { + let has_checksum = words.len() == CLASSIC_SEED_LENGTH_WITH_CHECKSUM; + if has_checksum && lang == Language::EnglishOld { + Err(SeedError::EnglishOldWithChecksum)?; + } + + // Validate words are in the language word list + let lang_word_list: &WordList = &LANGUAGES()[&lang]; + let matched_indices = (|| { let has_checksum = words.len() == CLASSIC_SEED_LENGTH_WITH_CHECKSUM; let mut matched_indices = Zeroizing::new(vec![]); - // Iterate through all the languages - 'language: for (lang_name, lang) in LANGUAGES() { - matched_indices.zeroize(); - matched_indices.clear(); - - // Iterate through all the words and see if they're all present - for word in &words { - let trimmed = trim(word, lang.unique_prefix_length); - let word = if has_checksum { &trimmed } else { word }; - - if let Some(index) = if has_checksum { - lang.trimmed_word_map.get(word.deref()) - } else { - lang.word_map.get(&word.as_str()) - } { - matched_indices.push(*index); - } else { - continue 'language; - } + // 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 { - if lang_name == &Language::EnglishOld { - Err(SeedError::EnglishOldWithChecksum)?; - } - - // 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)].clone(); + 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.unique_prefix_length) != trim(&last_word, lang.unique_prefix_length) - { - Err(SeedError::InvalidChecksum)?; - } + // 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)?; } - - return Ok((matched_indices, lang_name, lang)); } - Err(SeedError::UnknownLanguage)? + return Ok(matched_indices); })()?; // convert to bytes @@ -254,16 +249,17 @@ pub(crate) fn seed_to_bytes(words: &str) -> Result<(Language, Zeroizing<[u8; 32] indices[3] = matched_indices[i3 + 2]; let inner = |i| { - let mut base = (lang.word_list.len() - indices[i] + indices[i + 1]) % lang.word_list.len(); + 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.len(); + 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.len()) != indices[1] { + if (indices[0] % lang_word_list.word_list.len()) != indices[1] { Err(SeedError::InvalidSeed)?; } @@ -273,19 +269,19 @@ pub(crate) fn seed_to_bytes(words: &str) -> Result<(Language, Zeroizing<[u8; 32] bytes.zeroize(); } - Ok((*lang_name, res)) + Ok(res) } #[derive(Clone, PartialEq, Eq, Zeroize)] -pub struct ClassicSeed(Zeroizing); +pub struct ClassicSeed(Language, Zeroizing); impl ClassicSeed { pub(crate) fn new(rng: &mut R, lang: Language) -> ClassicSeed { key_to_seed(lang, Zeroizing::new(random_scalar(rng))) } #[allow(clippy::needless_pass_by_value)] - pub fn from_string(words: Zeroizing) -> Result { - let (lang, entropy) = seed_to_bytes(&words)?; + 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); @@ -306,10 +302,10 @@ impl ClassicSeed { } pub(crate) fn to_string(&self) -> Zeroizing { - self.0.clone() + self.1.clone() } pub(crate) fn entropy(&self) -> Zeroizing<[u8; 32]> { - seed_to_bytes(&self.0).unwrap().1 + seed_to_bytes(self.0, &self.1).unwrap() } } diff --git a/coins/monero/src/wallet/seed/mod.rs b/coins/monero/src/wallet/seed/mod.rs index 22ba37b07..3cb2911e2 100644 --- a/coins/monero/src/wallet/seed/mod.rs +++ b/coins/monero/src/wallet/seed/mod.rs @@ -61,13 +61,23 @@ impl Seed { } /// Parse a seed from a `String`. - pub fn from_string(words: Zeroizing) -> Result { - match words.split_whitespace().count() { - CLASSIC_SEED_LENGTH | CLASSIC_SEED_LENGTH_WITH_CHECKSUM => { - ClassicSeed::from_string(words).map(Seed::Classic) + pub fn from_string(seed_type: SeedType, words: Zeroizing) -> Result { + let word_count = words.split_whitespace().count(); + match seed_type { + SeedType::Classic(lang) => { + if word_count != CLASSIC_SEED_LENGTH && word_count != CLASSIC_SEED_LENGTH_WITH_CHECKSUM { + Err(SeedError::InvalidSeedLength)? + } else { + ClassicSeed::from_string(lang, words).map(Seed::Classic) + } + } + SeedType::Polyseed(lang) => { + if word_count != POLYSEED_LENGTH { + Err(SeedError::InvalidSeedLength)? + } else { + Polyseed::from_string(lang, words).map(Seed::Polyseed) + } } - POLYSEED_LENGTH => Polyseed::from_string(words).map(Seed::Polyseed), - _ => Err(SeedError::InvalidSeedLength)?, } } diff --git a/coins/monero/src/wallet/seed/polyseed.rs b/coins/monero/src/wallet/seed/polyseed.rs index a4f62506b..519ba7d4a 100644 --- a/coins/monero/src/wallet/seed/polyseed.rs +++ b/coins/monero/src/wallet/seed/polyseed.rs @@ -263,67 +263,62 @@ impl Polyseed { /// Create a new `Polyseed` from a String. #[allow(clippy::needless_pass_by_value)] - pub fn from_string(seed: Zeroizing) -> Result { + pub fn from_string(lang: Language, seed: Zeroizing) -> Result { // Decode the seed into its polynomial coefficients let mut poly = [0; POLYSEED_LENGTH]; - let lang = (|| { - 'language: for (name, lang) in LANGUAGES() { - for (i, word) in seed.split_whitespace().enumerate() { - // Find the word's index - fn check_if_matches, I: Iterator>( - has_prefix: bool, - mut lang_words: I, - word: &str, - ) -> Option { - if has_prefix { - // Get the position of the word within the iterator - // Doesn't use starts_with and some words are substrs of others, leading to false - // positives - let mut get_position = || { - lang_words.position(|lang_word| { - let mut lang_word = lang_word.as_ref().chars(); - let mut word = word.chars(); - - let mut res = true; - for _ in 0 .. PREFIX_LEN { - res &= lang_word.next() == word.next(); - } - res - }) - }; - let res = get_position(); - // If another word has this prefix, don't call it a match - if get_position().is_some() { - return None; + + // Validate words are in the lang word list + let lang_word_list: &WordList = &LANGUAGES()[&lang]; + for (i, word) in seed.split_whitespace().enumerate() { + // Find the word's index + fn check_if_matches, I: Iterator>( + has_prefix: bool, + mut lang_words: I, + word: &str, + ) -> Option { + if has_prefix { + // Get the position of the word within the iterator + // Doesn't use starts_with and some words are substrs of others, leading to false + // positives + let mut get_position = || { + lang_words.position(|lang_word| { + let mut lang_word = lang_word.as_ref().chars(); + let mut word = word.chars(); + + let mut res = true; + for _ in 0 .. PREFIX_LEN { + res &= lang_word.next() == word.next(); } res - } else { - lang_words.position(|lang_word| lang_word.as_ref() == word) - } - } - - let Some(coeff) = (if lang.has_accent { - let ascii = |word: &str| word.chars().filter(char::is_ascii).collect::(); - check_if_matches( - lang.has_prefix, - lang.words.iter().map(|lang_word| ascii(lang_word)), - &ascii(word), - ) - } else { - check_if_matches(lang.has_prefix, lang.words.iter(), word) - }) else { - continue 'language; + }) }; - - // WordList asserts the word list length is less than u16::MAX - poly[i] = u16::try_from(coeff).expect("coeff exceeded u16"); + let res = get_position(); + // If another word has this prefix, don't call it a match + if get_position().is_some() { + return None; + } + res + } else { + lang_words.position(|lang_word| lang_word.as_ref() == word) } - - return Ok(*name); } - Err(SeedError::UnknownLanguage) - })()?; + let Some(coeff) = (if lang_word_list.has_accent { + let ascii = |word: &str| word.chars().filter(char::is_ascii).collect::(); + check_if_matches( + lang_word_list.has_prefix, + lang_word_list.words.iter().map(|lang_word| ascii(lang_word)), + &ascii(word), + ) + } else { + check_if_matches(lang_word_list.has_prefix, lang_word_list.words.iter(), word) + }) else { + Err(SeedError::InvalidSeed)? + }; + + // WordList asserts the word list length is less than u16::MAX + poly[i] = u16::try_from(coeff).expect("coeff exceeded u16"); + } // xor out the coin poly[POLY_NUM_CHECK_DIGITS] ^= COIN;