#![cfg_attr(docsrs, feature(doc_auto_cfg))] #![doc = include_str!("../README.md")] #![deny(missing_docs)] #![cfg_attr(not(feature = "std"), no_std)] use core::fmt; use std_shims::{sync::OnceLock, string::String, collections::HashMap}; #[cfg(feature = "std")] use std::time::{SystemTime, UNIX_EPOCH}; use subtle::ConstantTimeEq; use zeroize::{Zeroize, Zeroizing, ZeroizeOnDrop}; use rand_core::{RngCore, CryptoRng}; use sha3::Sha3_256; use pbkdf2::pbkdf2_hmac; #[cfg(test)] mod tests; // Features const FEATURE_BITS: u8 = 5; #[allow(dead_code)] const INTERNAL_FEATURES: u8 = 2; const USER_FEATURES: u8 = 3; const USER_FEATURES_MASK: u8 = (1 << USER_FEATURES) - 1; const ENCRYPTED_MASK: u8 = 1 << 4; const RESERVED_FEATURES_MASK: u8 = ((1 << FEATURE_BITS) - 1) ^ ENCRYPTED_MASK; fn user_features(features: u8) -> u8 { features & USER_FEATURES_MASK } fn polyseed_features_supported(features: u8) -> bool { (features & RESERVED_FEATURES_MASK) == 0 } // Dates const DATE_BITS: u8 = 10; const DATE_MASK: u16 = (1u16 << DATE_BITS) - 1; const POLYSEED_EPOCH: u64 = 1635768000; // 1st November 2021 12:00 UTC const TIME_STEP: u64 = 2629746; // 30.436875 days = 1/12 of the Gregorian year // After ~85 years, this will roll over. fn birthday_encode(time: u64) -> u16 { u16::try_from((time.saturating_sub(POLYSEED_EPOCH) / TIME_STEP) & u64::from(DATE_MASK)) .expect("value masked by 2**10 - 1 didn't fit into a u16") } fn birthday_decode(birthday: u16) -> u64 { POLYSEED_EPOCH + (u64::from(birthday) * TIME_STEP) } // Polyseed parameters const SECRET_BITS: usize = 150; const BITS_PER_BYTE: usize = 8; const SECRET_SIZE: usize = SECRET_BITS.div_ceil(BITS_PER_BYTE); // 19 const CLEAR_BITS: usize = (SECRET_SIZE * BITS_PER_BYTE) - SECRET_BITS; // 2 // Polyseed calls this CLEAR_MASK and has a very complicated formula for this fundamental // equivalency #[allow(clippy::cast_possible_truncation)] const LAST_BYTE_SECRET_BITS_MASK: u8 = ((1 << (BITS_PER_BYTE - CLEAR_BITS)) - 1) as u8; const SECRET_BITS_PER_WORD: usize = 10; // The amount of words in a seed. const POLYSEED_LENGTH: usize = 16; // Amount of characters each word must have if trimmed pub(crate) const PREFIX_LEN: usize = 4; const POLY_NUM_CHECK_DIGITS: usize = 1; const DATA_WORDS: usize = POLYSEED_LENGTH - POLY_NUM_CHECK_DIGITS; // Polynomial const GF_BITS: usize = 11; const POLYSEED_MUL2_TABLE: [u16; 8] = [5, 7, 1, 3, 13, 15, 9, 11]; type Poly = [u16; POLYSEED_LENGTH]; fn elem_mul2(x: u16) -> u16 { if x < 1024 { return 2 * x; } POLYSEED_MUL2_TABLE[usize::from(x % 8)] + (16 * ((x - 1024) / 8)) } fn poly_eval(poly: &Poly) -> u16 { // Horner's method at x = 2 let mut result = poly[POLYSEED_LENGTH - 1]; for i in (0 .. (POLYSEED_LENGTH - 1)).rev() { result = elem_mul2(result) ^ poly[i]; } result } // Key gen parameters const POLYSEED_SALT: &[u8] = b"POLYSEED key"; const POLYSEED_KEYGEN_ITERATIONS: u32 = 10000; // Polyseed technically supports multiple coins, and the value for Monero is 0 // See: https://github.com/tevador/polyseed/blob/master/include/polyseed.h#L58 const COIN: u16 = 0; /// An error when working with a Polyseed. #[derive(Clone, Copy, PartialEq, Eq, Debug)] #[cfg_attr(feature = "std", derive(thiserror::Error))] pub enum PolyseedError { /// The seed was invalid. #[cfg_attr(feature = "std", error("invalid seed"))] InvalidSeed, /// The entropy was invalid. #[cfg_attr(feature = "std", error("invalid entropy"))] InvalidEntropy, /// The checksum did not match the data. #[cfg_attr(feature = "std", error("invalid checksum"))] InvalidChecksum, /// Unsupported feature bits were set. #[cfg_attr(feature = "std", error("unsupported features"))] UnsupportedFeatures, } /// Language options for Polyseed. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Zeroize)] pub enum Language { /// English language option. English, /// Spanish language option. Spanish, /// French language option. French, /// Italian language option. Italian, /// Japanese language option. Japanese, /// Korean language option. Korean, /// Czech language option. Czech, /// Portuguese language option. Portuguese, /// Simplified Chinese language option. ChineseSimplified, /// Traditional Chinese language option. ChineseTraditional, } struct WordList { words: &'static [&'static str], has_prefix: bool, has_accent: bool, } impl WordList { fn new(words: &'static [&'static str], has_prefix: bool, has_accent: bool) -> WordList { let res = WordList { words, has_prefix, has_accent }; // This is needed for a later unwrap to not fails assert!(words.len() < usize::from(u16::MAX)); res } } static LANGUAGES_CELL: OnceLock> = OnceLock::new(); #[allow(non_snake_case)] fn LANGUAGES() -> &'static HashMap { LANGUAGES_CELL.get_or_init(|| { HashMap::from([ (Language::Czech, WordList::new(include!("./words/cs.rs"), true, false)), (Language::French, WordList::new(include!("./words/fr.rs"), true, true)), (Language::Korean, WordList::new(include!("./words/ko.rs"), false, false)), (Language::English, WordList::new(include!("./words/en.rs"), true, false)), (Language::Italian, WordList::new(include!("./words/it.rs"), true, false)), (Language::Spanish, WordList::new(include!("./words/es.rs"), true, true)), (Language::Japanese, WordList::new(include!("./words/ja.rs"), false, false)), (Language::Portuguese, WordList::new(include!("./words/pt.rs"), true, false)), ( Language::ChineseSimplified, WordList::new(include!("./words/zh_simplified.rs"), false, false), ), ( Language::ChineseTraditional, WordList::new(include!("./words/zh_traditional.rs"), false, false), ), ]) }) } /// A Polyseed. #[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] pub struct Polyseed { language: Language, features: u8, birthday: u16, entropy: Zeroizing<[u8; 32]>, checksum: u16, } impl fmt::Debug for Polyseed { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.debug_struct("Polyseed").finish_non_exhaustive() } } fn valid_entropy(entropy: &Zeroizing<[u8; 32]>) -> bool { // Last byte of the entropy should only use certain bits let mut res = entropy[SECRET_SIZE - 1].ct_eq(&(entropy[SECRET_SIZE - 1] & LAST_BYTE_SECRET_BITS_MASK)); // Last 13 bytes of the buffer should be unused for b in SECRET_SIZE .. entropy.len() { res &= entropy[b].ct_eq(&0); } res.into() } impl Polyseed { // TODO: Clean this fn to_poly(&self) -> Poly { let mut extra_bits = u32::from(FEATURE_BITS + DATE_BITS); let extra_val = (u16::from(self.features) << DATE_BITS) | self.birthday; let mut entropy_idx = 0; let mut secret_bits = BITS_PER_BYTE; let mut seed_rem_bits = SECRET_BITS - BITS_PER_BYTE; let mut poly = [0; POLYSEED_LENGTH]; for i in 0 .. DATA_WORDS { extra_bits -= 1; let mut word_bits = 0; let mut word_val = 0; while word_bits < SECRET_BITS_PER_WORD { if secret_bits == 0 { entropy_idx += 1; secret_bits = seed_rem_bits.min(BITS_PER_BYTE); seed_rem_bits -= secret_bits; } let chunk_bits = secret_bits.min(SECRET_BITS_PER_WORD - word_bits); secret_bits -= chunk_bits; word_bits += chunk_bits; word_val <<= chunk_bits; word_val |= (u16::from(self.entropy[entropy_idx]) >> secret_bits) & ((1u16 << chunk_bits) - 1); } word_val <<= 1; word_val |= (extra_val >> extra_bits) & 1; poly[POLY_NUM_CHECK_DIGITS + i] = word_val; } poly } fn from_internal( language: Language, masked_features: u8, encoded_birthday: u16, entropy: Zeroizing<[u8; 32]>, ) -> Result { if !polyseed_features_supported(masked_features) { Err(PolyseedError::UnsupportedFeatures)?; } if !valid_entropy(&entropy) { Err(PolyseedError::InvalidEntropy)?; } let mut res = Polyseed { language, birthday: encoded_birthday, features: masked_features, entropy, checksum: 0, }; res.checksum = poly_eval(&res.to_poly()); Ok(res) } /// Create a new `Polyseed` with specific internals. /// /// `birthday` is defined in seconds since the epoch. pub fn from( language: Language, features: u8, birthday: u64, entropy: Zeroizing<[u8; 32]>, ) -> Result { Self::from_internal(language, user_features(features), birthday_encode(birthday), entropy) } /// Create a new `Polyseed`. /// /// This uses the system's time for the birthday, if available, else 0. pub fn new(rng: &mut R, language: Language) -> Polyseed { // Get the birthday #[cfg(feature = "std")] let birthday = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(core::time::Duration::ZERO).as_secs(); #[cfg(not(feature = "std"))] let birthday = 0; // Derive entropy let mut entropy = Zeroizing::new([0; 32]); rng.fill_bytes(entropy.as_mut()); entropy[SECRET_SIZE ..].fill(0); entropy[SECRET_SIZE - 1] &= LAST_BYTE_SECRET_BITS_MASK; Self::from(language, 0, birthday, entropy).unwrap() } /// Create a new `Polyseed` from a String. #[allow(clippy::needless_pass_by_value)] pub fn from_string(lang: Language, seed: Zeroizing) -> Result { // Decode the seed into its polynomial coefficients let mut poly = [0; POLYSEED_LENGTH]; // 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 }) }; 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) } } 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(PolyseedError::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; // Validate the checksum if poly_eval(&poly) != 0 { Err(PolyseedError::InvalidChecksum)?; } // Convert the polynomial into entropy let mut entropy = Zeroizing::new([0; 32]); let mut extra = 0; let mut entropy_idx = 0; let mut entropy_bits = 0; let checksum = poly[0]; for mut word_val in poly.into_iter().skip(POLY_NUM_CHECK_DIGITS) { // Parse the bottom bit, which is one of the bits of extra // This iterates for less than 16 iters, meaning this won't drop any bits extra <<= 1; extra |= word_val & 1; word_val >>= 1; // 10 bits per word creates a [8, 2], [6, 4], [4, 6], [2, 8] cycle // 15 % 4 is 3, leaving 2 bits off, and 152 (19 * 8) - 2 is 150, the amount of bits in the // secret let mut word_bits = GF_BITS - 1; while word_bits > 0 { if entropy_bits == BITS_PER_BYTE { entropy_idx += 1; entropy_bits = 0; } let chunk_bits = word_bits.min(BITS_PER_BYTE - entropy_bits); word_bits -= chunk_bits; let chunk_mask = (1u16 << chunk_bits) - 1; if chunk_bits < BITS_PER_BYTE { entropy[entropy_idx] <<= chunk_bits; } entropy[entropy_idx] |= u8::try_from((word_val >> word_bits) & chunk_mask).expect("chunk exceeded u8"); entropy_bits += chunk_bits; } } let birthday = extra & DATE_MASK; // extra is contained to u16, and DATE_BITS > 8 let features = u8::try_from(extra >> DATE_BITS).expect("couldn't convert extra >> DATE_BITS to u8"); let res = Self::from_internal(lang, features, birthday, entropy); if let Ok(res) = res.as_ref() { debug_assert_eq!(res.checksum, checksum); } res } /// When this seed was created, defined in seconds since the epoch. pub fn birthday(&self) -> u64 { birthday_decode(self.birthday) } /// This seed's features. pub fn features(&self) -> u8 { self.features } /// This seed's entropy. pub fn entropy(&self) -> &Zeroizing<[u8; 32]> { &self.entropy } /// The key derived from this seed. pub fn key(&self) -> Zeroizing<[u8; 32]> { let mut key = Zeroizing::new([0; 32]); pbkdf2_hmac::( self.entropy.as_slice(), POLYSEED_SALT, POLYSEED_KEYGEN_ITERATIONS, key.as_mut(), ); key } /// The String representation of this seed. pub fn to_string(&self) -> Zeroizing { // Encode the polynomial with the existing checksum let mut poly = self.to_poly(); poly[0] = self.checksum; // Embed the coin poly[POLY_NUM_CHECK_DIGITS] ^= COIN; // Output words let mut seed = Zeroizing::new(String::new()); let words = &LANGUAGES()[&self.language].words; for i in 0 .. poly.len() { seed.push_str(words[usize::from(poly[i])]); if i < poly.len() - 1 { seed.push(' '); } } seed } }