Skip to content

Commit

Permalink
monero: require seed lang when decoding seed
Browse files Browse the repository at this point in the history
- Require the seed language when decoding a Classic|Polyseed seed string
	- As per monero-project/monero#9089 and tevador/polyseed#11
	- Fixes serai-dex#478
	- Implementation note: I reused the `SeedType` enum and required it as a param to `Seed::from_string` because it seemed simplest, but perhaps there is a cleaner way to require the seed lang.
- Made sure the print statements from serai-dex#487 print the seed as early as possible to help debug future issues
- A future PR could support deducing which languages a seed decodes to in order to support the UX @kayabaNerve suggested in monero-project/monero#9089:
	- "Wallets can also try to abstract [language specification], by decoding with all languages, and only asking the user if/when multiple valid options show up ("Is this seed Spanish or Italian?")."
  • Loading branch information
j-berman committed Jan 3, 2024
1 parent 6c8040f commit fc60150
Show file tree
Hide file tree
Showing 4 changed files with 202 additions and 137 deletions.
122 changes: 93 additions & 29 deletions coins/monero/src/tests/seed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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()
);
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}

Expand All @@ -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(
Expand Down
94 changes: 45 additions & 49 deletions coins/monero/src/wallet/seed/classic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -184,63 +184,58 @@ fn key_to_seed(lang: Language, key: Zeroizing<Scalar>) -> 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<Zeroizing<[u8; 32]>, SeedError> {
// get seed words
let words = words.split_whitespace().map(|w| Zeroizing::new(w.to_string())).collect::<Vec<_>>();
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
Expand All @@ -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)?;
}

Expand All @@ -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<String>);
pub struct ClassicSeed(Language, Zeroizing<String>);
impl ClassicSeed {
pub(crate) fn new<R: RngCore + CryptoRng>(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<String>) -> Result<ClassicSeed, SeedError> {
let (lang, entropy) = seed_to_bytes(&words)?;
pub fn from_string(lang: Language, words: Zeroizing<String>) -> Result<ClassicSeed, SeedError> {
let entropy = seed_to_bytes(lang, &words)?;

// Make sure this is a valid scalar
let scalar = Scalar::from_canonical_bytes(*entropy);
Expand All @@ -306,10 +302,10 @@ impl ClassicSeed {
}

pub(crate) fn to_string(&self) -> Zeroizing<String> {
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()
}
}
22 changes: 16 additions & 6 deletions coins/monero/src/wallet/seed/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,23 @@ impl Seed {
}

/// Parse a seed from a `String`.
pub fn from_string(words: Zeroizing<String>) -> Result<Seed, SeedError> {
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<String>) -> Result<Seed, SeedError> {
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)?,
}
}

Expand Down
Loading

0 comments on commit fc60150

Please sign in to comment.