montana/Montana-Protocol/Code/crates/mt-mnemonic/src/mnemonic.rs

258 lines
9.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// spec, раздел "Ключи → Мнемоника и seed"
use mt_codec::domain;
use mt_crypto::sha256_raw;
use crate::bit_packing::{pack_indices_to_bytes, unpack_bytes_to_indices, PACKED_BYTES};
use crate::hkdf::hkdf_expand;
use crate::pbkdf2::pbkdf2_hmac_sha256;
use crate::wordlist::{word_index, wordlist};
pub const MNEMONIC_WORD_COUNT: usize = 24;
pub const KDF_ITER: u32 = 1_048_576; // = 2^20
pub const MASTER_SEED_LEN: usize = 64;
// spec: ML-DSA-65 seed (FIPS 204 §3.1, ξ ∈ B32) — 32 байта (was Falcon 48).
pub const MLDSA_SEED_LEN: usize = 32;
pub const MLKEM_SEED_LEN: usize = 64;
// libp2p Ed25519 transport identity seed (RFC 8032 Ed25519 secret = 32 байта).
pub const ED25519_SEED_LEN: usize = 32;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum MnemonicError {
WordCount(usize),
UnknownWord(usize),
ChecksumMismatch,
}
impl core::fmt::Display for MnemonicError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::WordCount(n) => write!(f, "expected 24 words, got {n}"),
Self::UnknownWord(pos) => write!(
f,
"word at position {pos} is not in canonical Montana wordlist"
),
Self::ChecksumMismatch => write!(f, "mnemonic checksum mismatch"),
}
}
}
impl std::error::Error for MnemonicError {}
pub fn mnemonic_to_master_seed(mnemonic: &str) -> Result<[u8; MASTER_SEED_LEN], MnemonicError> {
// split_whitespace вместо split(' ') — UX-friendly: пользователь
// может скопировать мнемонику с tab-ами, multiple spaces, либо
// newlines между словами (типичный случай при copy-paste из менеджера
// паролей). Strict single-space parsing давал false `WordCount` либо
// `UnknownWord` ошибки на безобидных whitespace вариациях.
let words: Vec<&str> = mnemonic.split_whitespace().collect();
if words.len() != MNEMONIC_WORD_COUNT {
return Err(MnemonicError::WordCount(words.len()));
}
let mut indices = [0u16; MNEMONIC_WORD_COUNT];
for (i, w) in words.iter().enumerate() {
indices[i] = word_index(w).ok_or(MnemonicError::UnknownWord(i))?;
}
let buf: [u8; PACKED_BYTES] = pack_indices_to_bytes(&indices);
let entropy: [u8; 32] = {
let mut arr = [0u8; 32];
arr.copy_from_slice(&buf[0..32]);
arr
};
let checksum_provided = buf[32];
let checksum_computed = sha256_raw(&entropy)[0];
if checksum_provided != checksum_computed {
return Err(MnemonicError::ChecksumMismatch);
}
let dk = pbkdf2_hmac_sha256(&entropy, domain::SEED, KDF_ITER, MASTER_SEED_LEN);
let mut master_seed = [0u8; MASTER_SEED_LEN];
master_seed.copy_from_slice(&dk);
Ok(master_seed)
}
pub fn mldsa_seed_for_role(
master_seed: &[u8; MASTER_SEED_LEN],
role: &[u8],
) -> [u8; MLDSA_SEED_LEN] {
let dk = hkdf_expand(master_seed, role, MLDSA_SEED_LEN);
let mut out = [0u8; MLDSA_SEED_LEN];
out.copy_from_slice(&dk);
out
}
pub fn mlkem_seed_for_role(
master_seed: &[u8; MASTER_SEED_LEN],
role: &[u8],
) -> [u8; MLKEM_SEED_LEN] {
let dk = hkdf_expand(master_seed, role, MLKEM_SEED_LEN);
let mut out = [0u8; MLKEM_SEED_LEN];
out.copy_from_slice(&dk);
out
}
pub fn ed25519_seed_for_role(
master_seed: &[u8; MASTER_SEED_LEN],
role: &[u8],
) -> [u8; ED25519_SEED_LEN] {
let dk = hkdf_expand(master_seed, role, ED25519_SEED_LEN);
let mut out = [0u8; ED25519_SEED_LEN];
out.copy_from_slice(&dk);
out
}
pub fn entropy_to_mnemonic(entropy: &[u8; 32]) -> String {
let checksum = sha256_raw(entropy)[0];
let mut buf = [0u8; PACKED_BYTES];
buf[..32].copy_from_slice(entropy);
buf[32] = checksum;
let indices = unpack_bytes_to_indices(&buf);
let wl = wordlist();
let words: Vec<&str> = indices.iter().map(|&i| wl[i as usize]).collect();
words.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_length_zero_words() {
// split_whitespace на пустой строке даёт 0 элементов (vs split(' ')
// даёт 1 пустой элемент). Closure F-12 (split_whitespace вместо
// split(' ')) изменил это поведение в UX-friendly сторону.
let err = mnemonic_to_master_seed("").unwrap_err();
assert_eq!(err, MnemonicError::WordCount(0));
}
#[test]
fn whitespace_tolerant_parsing() {
// F-12 closure: пользователь может скопировать мнемонику с tab-ами,
// multiple spaces либо newlines между словами — split_whitespace
// tolerates все эти случаи.
let m = ["abandon"; 24].join(" "); // double space между словами
let err = mnemonic_to_master_seed(&m).unwrap_err();
// 24 слова парсятся правильно (не WordCount), но "abandon" × 24
// не имеет valid checksum → ChecksumMismatch
assert_eq!(err, MnemonicError::ChecksumMismatch);
let m_tabs = ["abandon"; 24].join("\t");
let err_tabs = mnemonic_to_master_seed(&m_tabs).unwrap_err();
assert_eq!(err_tabs, MnemonicError::ChecksumMismatch);
let m_newlines = ["abandon"; 24].join("\n");
let err_newlines = mnemonic_to_master_seed(&m_newlines).unwrap_err();
assert_eq!(err_newlines, MnemonicError::ChecksumMismatch);
}
#[test]
fn invalid_length_one_word() {
let err = mnemonic_to_master_seed("abandon").unwrap_err();
assert_eq!(err, MnemonicError::WordCount(1));
}
#[test]
fn invalid_length_23_words() {
let m = "abandon ".repeat(22) + "abandon";
let err = mnemonic_to_master_seed(&m).unwrap_err();
assert_eq!(err, MnemonicError::WordCount(23));
}
#[test]
fn invalid_word_at_position_0() {
let m = format!("bogus {}", "abandon ".repeat(23).trim_end());
let err = mnemonic_to_master_seed(&m).unwrap_err();
assert_eq!(err, MnemonicError::UnknownWord(0));
}
#[test]
fn invalid_checksum_all_abandon() {
// "abandon" × 24 имеет неверный checksum (checksum последнего слова должен быть
// SHA-256(zeros_32)[0] = 0x66, что соответствует 24-му слову "art" (index 102).
let m = "abandon ".repeat(23) + "abandon";
let err = mnemonic_to_master_seed(&m).unwrap_err();
assert_eq!(err, MnemonicError::ChecksumMismatch);
}
#[test]
fn entropy_zero_roundtrip_successful() {
let entropy = [0u8; 32];
let mnemonic = entropy_to_mnemonic(&entropy);
let master_seed = mnemonic_to_master_seed(&mnemonic).expect("valid mnemonic");
assert_eq!(master_seed.len(), 64);
}
#[test]
fn entropy_zero_produces_23_abandon_plus_art() {
let entropy = [0u8; 32];
let mnemonic = entropy_to_mnemonic(&entropy);
let expected_last = "art"; // BIP-39 standard: zero entropy + checksum 0x66 → word 102
let words: Vec<&str> = mnemonic.split(' ').collect();
for w in words.iter().take(23) {
assert_eq!(*w, "abandon");
}
assert_eq!(words[23], expected_last);
}
#[test]
fn determinism_mnemonic_to_master_seed() {
let entropy = [0xabu8; 32];
let mnemonic = entropy_to_mnemonic(&entropy);
let a = mnemonic_to_master_seed(&mnemonic).unwrap();
let b = mnemonic_to_master_seed(&mnemonic).unwrap();
assert_eq!(a, b);
}
#[test]
fn per_role_derivations_differ() {
let entropy = [0x11u8; 32];
let mnemonic = entropy_to_mnemonic(&entropy);
let master = mnemonic_to_master_seed(&mnemonic).unwrap();
let mldsa_acc = mldsa_seed_for_role(&master, domain::ACCOUNT_KEY);
let mldsa_node = mldsa_seed_for_role(&master, domain::NODE_KEY);
let mlkem_app = mlkem_seed_for_role(&master, domain::APP_ENCRYPTION_KEY);
assert_ne!(mldsa_acc[..], mldsa_node[..]);
assert_ne!(&mldsa_acc[..], &mlkem_app[..MLDSA_SEED_LEN]);
}
#[test]
fn per_role_derivation_determinism() {
let master = [0x55u8; 64];
let a = mldsa_seed_for_role(&master, domain::ACCOUNT_KEY);
let b = mldsa_seed_for_role(&master, domain::ACCOUNT_KEY);
assert_eq!(a, b);
}
#[test]
fn mldsa_seed_len_is_32() {
// FIPS 204 §3.1: ξ ∈ B32 (ML-DSA-65 KeyGen_internal seed)
assert_eq!(MLDSA_SEED_LEN, 32);
}
#[test]
fn ed25519_seed_len_is_32() {
// RFC 8032 Ed25519 secret key = 32 bytes
assert_eq!(ED25519_SEED_LEN, 32);
}
#[test]
fn ed25519_seed_for_role_determinism() {
let master = [0x77u8; 64];
let a = ed25519_seed_for_role(&master, domain::LIBP2P_TRANSPORT_KEY);
let b = ed25519_seed_for_role(&master, domain::LIBP2P_TRANSPORT_KEY);
assert_eq!(a, b);
}
#[test]
fn ed25519_seed_differs_from_mldsa_node_for_same_master() {
// libp2p transport identity must NOT collide with consensus node key
let master = [0x33u8; 64];
let libp2p = ed25519_seed_for_role(&master, domain::LIBP2P_TRANSPORT_KEY);
let node = mldsa_seed_for_role(&master, domain::NODE_KEY);
assert_ne!(libp2p[..], node[..]);
}
}