mirror of
https://github.com/serai-dex/serai.git
synced 2025-01-15 07:14:57 +00:00
880565cb81
Some checks failed
Coordinator Tests / build (push) Has been cancelled
crypto/ Tests / test-crypto (push) Has been cancelled
Full Stack Tests / build (push) Has been cancelled
Lint / clippy (macos-13) (push) Has been cancelled
Lint / clippy (macos-14) (push) Has been cancelled
Lint / clippy (ubuntu-latest) (push) Has been cancelled
Lint / clippy (windows-latest) (push) Has been cancelled
Lint / deny (push) Has been cancelled
Lint / fmt (push) Has been cancelled
Lint / machete (push) Has been cancelled
Message Queue Tests / build (push) Has been cancelled
Monero Tests / unit-tests (push) Has been cancelled
Reproducible Runtime / build (push) Has been cancelled
Tests / test-infra (push) Has been cancelled
common/ Tests / test-common (push) Has been cancelled
Monero Tests / integration-tests (v0.17.3.2) (push) Has been cancelled
Monero Tests / integration-tests (v0.18.2.0) (push) Has been cancelled
networks/ Tests / test-networks (push) Has been cancelled
no-std build / build (push) Has been cancelled
Processor Tests / build (push) Has been cancelled
Tests / test-substrate (push) Has been cancelled
Tests / test-serai-client (push) Has been cancelled
Preserves the fn accessors within the Monero crates so that we can use statics in some cfgs yet not all (in order to provide support for more low-memory devices) with the exception of `H` (which truly should be cached).
473 lines
14 KiB
Rust
473 lines
14 KiB
Rust
#![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::LazyLock, 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/dfb05d8edb682b0e8f743b1b70c9131712ff4157
|
|
// /include/polyseed.h#L57
|
|
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: LazyLock<HashMap<Language, WordList>> = LazyLock::new(|| {
|
|
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<Polyseed, PolyseedError> {
|
|
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<Polyseed, PolyseedError> {
|
|
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<R: RngCore + CryptoRng>(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<String>) -> Result<Polyseed, PolyseedError> {
|
|
// 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<S: AsRef<str>, I: Iterator<Item = S>>(
|
|
has_prefix: bool,
|
|
mut lang_words: I,
|
|
word: &str,
|
|
) -> Option<usize> {
|
|
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::<String>();
|
|
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::<Sha3_256>(
|
|
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<String> {
|
|
// 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
|
|
}
|
|
}
|