Smash out polyseed

This commit is contained in:
Luke Parker
2024-06-23 05:34:41 -04:00
parent df095f027f
commit 1e2e3bd5ce
24 changed files with 210 additions and 115 deletions

View File

@@ -0,0 +1,44 @@
[package]
name = "polyseed"
version = "0.1.0"
description = "Rust implementation of Polyseed"
license = "MIT"
repository = "https://github.com/serai-dex/serai/tree/develop/coins/monero/wallet.polyseed"
authors = ["Luke Parker <lukeparker5132@gmail.com>"]
edition = "2021"
rust-version = "1.79"
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[lints]
workspace = true
[dependencies]
std-shims = { path = "../../../../common/std-shims", version = "^0.1.1", default-features = false }
thiserror = { version = "1", default-features = false, optional = true }
subtle = { version = "^2.4", default-features = false }
zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] }
rand_core = { version = "0.6", default-features = false }
sha3 = { version = "0.10", default-features = false }
pbkdf2 = { version = "0.12", features = ["simple"], default-features = false }
[features]
std = [
"std-shims/std",
"thiserror",
"subtle/std",
"zeroize/std",
"rand_core/std",
"sha3/std",
"pbkdf2/std",
]
default = ["std"]

View File

@@ -0,0 +1,6 @@
# Polyseed
Rust implementation of [Polyseed](https://github.com/tevador/polyseed).
This library is usable under no-std when the `std` feature (on by default) is
disabled.

View File

@@ -0,0 +1,473 @@
#![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;
// 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;
// Amount of words in a seed
const POLYSEED_LENGTH: usize = 16;
// Amount of characters each word must have if trimmed
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 {
/// Unsupported feature bits were set.
#[cfg_attr(feature = "std", error("unsupported features"))]
UnsupportedFeatures,
/// The entropy was invalid.
#[cfg_attr(feature = "std", error("invalid entropy"))]
InvalidEntropy,
#[cfg_attr(feature = "std", error("invalid seed"))]
/// The seed was invalid.
InvalidSeed,
/// The checksum did not match the data.
#[cfg_attr(feature = "std", error("invalid checksum"))]
InvalidChecksum,
}
/// 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<HashMap<Language, WordList>> = OnceLock::new();
#[allow(non_snake_case)]
fn LANGUAGES() -> &'static HashMap<Language, WordList> {
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<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.
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
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff