mirror of
https://github.com/serai-dex/serai.git
synced 2024-12-23 03:59:22 +00:00
monero: require seed lang when decoding seed (#502)
* monero: require seed lang when decoding seed - Require the seed language when decoding a Classic|Polyseed seed string - As per https://github.com/monero-project/monero/issues/9089 and https://github.com/tevador/polyseed/issues/11 - Fixes #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 #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 https://github.com/monero-project/monero/issues/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?")." * Lint
This commit is contained in:
parent
7eb388e546
commit
265261d3ba
4 changed files with 203 additions and 138 deletions
|
@ -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(
|
||||
|
|
|
@ -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<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 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.trimmed_word_map.get(word.deref())
|
||||
lang_word_list.trimmed_word_map.get(word.deref())
|
||||
} else {
|
||||
lang.word_map.get(&word.as_str())
|
||||
lang_word_list.word_map.get(&word.as_str())
|
||||
} {
|
||||
matched_indices.push(*index);
|
||||
} else {
|
||||
continue 'language;
|
||||
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();
|
||||
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)
|
||||
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)?
|
||||
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<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);
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)?,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -263,11 +263,12 @@ impl Polyseed {
|
|||
|
||||
/// Create a new `Polyseed` from a String.
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub fn from_string(seed: Zeroizing<String>) -> Result<Polyseed, SeedError> {
|
||||
pub fn from_string(lang: Language, seed: Zeroizing<String>) -> Result<Polyseed, SeedError> {
|
||||
// Decode the seed into its polynomial coefficients
|
||||
let mut poly = [0; POLYSEED_LENGTH];
|
||||
let lang = (|| {
|
||||
'language: for (name, lang) in LANGUAGES() {
|
||||
|
||||
// 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<S: AsRef<str>, I: Iterator<Item = S>>(
|
||||
|
@ -302,29 +303,23 @@ impl Polyseed {
|
|||
}
|
||||
}
|
||||
|
||||
let Some(coeff) = (if lang.has_accent {
|
||||
let Some(coeff) = (if lang_word_list.has_accent {
|
||||
let ascii = |word: &str| word.chars().filter(char::is_ascii).collect::<String>();
|
||||
check_if_matches(
|
||||
lang.has_prefix,
|
||||
lang.words.iter().map(|lang_word| ascii(lang_word)),
|
||||
lang_word_list.has_prefix,
|
||||
lang_word_list.words.iter().map(|lang_word| ascii(lang_word)),
|
||||
&ascii(word),
|
||||
)
|
||||
} else {
|
||||
check_if_matches(lang.has_prefix, lang.words.iter(), word)
|
||||
check_if_matches(lang_word_list.has_prefix, lang_word_list.words.iter(), word)
|
||||
}) else {
|
||||
continue 'language;
|
||||
Err(SeedError::InvalidSeed)?
|
||||
};
|
||||
|
||||
// WordList asserts the word list length is less than u16::MAX
|
||||
poly[i] = u16::try_from(coeff).expect("coeff exceeded u16");
|
||||
}
|
||||
|
||||
return Ok(*name);
|
||||
}
|
||||
|
||||
Err(SeedError::UnknownLanguage)
|
||||
})()?;
|
||||
|
||||
// xor out the coin
|
||||
poly[POLY_NUM_CHECK_DIGITS] ^= COIN;
|
||||
|
||||
|
|
Loading…
Reference in a new issue