Skip to main content

bsv/compat/
bip39.rs

1//! BIP39 mnemonic generation and seed derivation.
2//!
3//! Implements mnemonic generation from entropy, mnemonic validation,
4//! and PBKDF2-based seed derivation per the BIP39 specification.
5//!
6//! Note: BIP39 spec requires NFKD normalization of mnemonic and passphrase.
7//! All 9 supported wordlists use ASCII-safe characters, so normalization is
8//! a no-op for the mnemonic itself. Non-ASCII passphrases should be
9//! pre-normalized by the caller.
10
11use super::bip39_wordlists;
12use super::error::CompatError;
13use crate::primitives::hash::{pbkdf2_hmac_sha512, sha256};
14use crate::primitives::random::random_bytes;
15
16/// Supported BIP39 wordlist languages.
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub enum Language {
19    English,
20    Japanese,
21    Korean,
22    Spanish,
23    French,
24    Italian,
25    Czech,
26    ChineseSimplified,
27    ChineseTraditional,
28}
29
30/// Returns the wordlist for the given language.
31fn get_wordlist(lang: Language) -> &'static [&'static str; 2048] {
32    match lang {
33        Language::English => bip39_wordlists::english::ENGLISH,
34        Language::Japanese => bip39_wordlists::japanese::JAPANESE,
35        Language::Korean => bip39_wordlists::korean::KOREAN,
36        Language::Spanish => bip39_wordlists::spanish::SPANISH,
37        Language::French => bip39_wordlists::french::FRENCH,
38        Language::Italian => bip39_wordlists::italian::ITALIAN,
39        Language::Czech => bip39_wordlists::czech::CZECH,
40        Language::ChineseSimplified => bip39_wordlists::chinese_simplified::CHINESE_SIMPLIFIED,
41        Language::ChineseTraditional => bip39_wordlists::chinese_traditional::CHINESE_TRADITIONAL,
42    }
43}
44
45/// A BIP39 mnemonic phrase with associated entropy and language.
46#[derive(Debug, Clone)]
47pub struct Mnemonic {
48    words: Vec<String>,
49    entropy: Vec<u8>,
50    language: Language,
51}
52
53impl Mnemonic {
54    /// Create a mnemonic from raw entropy bytes.
55    ///
56    /// Entropy must be 16, 20, 24, 28, or 32 bytes (128-256 bits in 32-bit steps).
57    /// The checksum is computed as the first `entropy_bits / 32` bits of SHA-256(entropy).
58    pub fn from_entropy(entropy: &[u8], language: Language) -> Result<Self, CompatError> {
59        let ent_bits = entropy.len() * 8;
60        if !(128..=256).contains(&ent_bits) || !ent_bits.is_multiple_of(32) {
61            return Err(CompatError::InvalidEntropy(format!(
62                "entropy must be 128-256 bits in 32-bit increments, got {ent_bits} bits"
63            )));
64        }
65
66        let checksum_bits = ent_bits / 32;
67        let checksum = sha256(entropy);
68
69        // Build bit stream: entropy bits + checksum bits
70        let total_bits = ent_bits + checksum_bits;
71        let wordlist = get_wordlist(language);
72        let mut words = Vec::with_capacity(total_bits / 11);
73
74        for i in 0..(total_bits / 11) {
75            let mut index: u32 = 0;
76            for j in 0..11 {
77                let bit_pos = i * 11 + j;
78                let bit = if bit_pos < ent_bits {
79                    // Bit from entropy
80                    (entropy[bit_pos / 8] >> (7 - (bit_pos % 8))) & 1
81                } else {
82                    // Bit from checksum
83                    let cs_pos = bit_pos - ent_bits;
84                    (checksum[cs_pos / 8] >> (7 - (cs_pos % 8))) & 1
85                };
86                index = (index << 1) | bit as u32;
87            }
88            words.push(wordlist[index as usize].to_string());
89        }
90
91        Ok(Mnemonic {
92            words,
93            entropy: entropy.to_vec(),
94            language,
95        })
96    }
97
98    /// Generate a random mnemonic with the specified bit strength.
99    ///
100    /// Valid bit strengths: 128, 160, 192, 224, 256.
101    pub fn from_random(bits: usize, language: Language) -> Result<Self, CompatError> {
102        if !(128..=256).contains(&bits) || !bits.is_multiple_of(32) {
103            return Err(CompatError::InvalidEntropy(format!(
104                "bits must be 128-256 in 32-bit increments, got {bits}"
105            )));
106        }
107        let entropy = random_bytes(bits / 8);
108        Self::from_entropy(&entropy, language)
109    }
110
111    /// Parse a mnemonic from a space-separated string.
112    ///
113    /// Validates that all words are in the wordlist and the checksum is correct.
114    /// Japanese mnemonics use ideographic space (U+3000) as separator.
115    pub fn from_string(mnemonic: &str, language: Language) -> Result<Self, CompatError> {
116        let separator = if language == Language::Japanese {
117            "\u{3000}"
118        } else {
119            " "
120        };
121
122        let word_strs: Vec<&str> = mnemonic.split(separator).collect();
123        let word_count = word_strs.len();
124
125        // Valid word counts: 12, 15, 18, 21, 24
126        if !(12..=24).contains(&word_count) || !word_count.is_multiple_of(3) {
127            return Err(CompatError::InvalidMnemonic(format!(
128                "invalid word count: {word_count} (must be 12, 15, 18, 21, or 24)"
129            )));
130        }
131
132        let wordlist = get_wordlist(language);
133
134        // Look up indices
135        let mut indices = Vec::with_capacity(word_count);
136        for word in &word_strs {
137            match wordlist.iter().position(|w| w == word) {
138                Some(idx) => indices.push(idx as u32),
139                None => {
140                    return Err(CompatError::InvalidMnemonic(format!(
141                        "word not in wordlist: {word}"
142                    )));
143                }
144            }
145        }
146
147        // Reconstruct entropy from 11-bit indices
148        let total_bits = word_count * 11;
149        let ent_bits = (total_bits * 32) / 33; // entropy_bits = total_bits - checksum_bits
150        let checksum_bits = ent_bits / 32;
151        let ent_bytes = ent_bits / 8;
152
153        // Extract all bits from indices
154        let mut bits_vec: Vec<u8> = Vec::with_capacity(total_bits);
155        for idx in &indices {
156            for j in (0..11).rev() {
157                bits_vec.push(((idx >> j) & 1) as u8);
158            }
159        }
160
161        // Extract entropy bytes
162        let mut entropy = vec![0u8; ent_bytes];
163        for i in 0..ent_bits {
164            if bits_vec[i] == 1 {
165                entropy[i / 8] |= 1 << (7 - (i % 8));
166            }
167        }
168
169        // Validate checksum
170        let checksum = sha256(&entropy);
171        for i in 0..checksum_bits {
172            let expected_bit = (checksum[i / 8] >> (7 - (i % 8))) & 1;
173            let actual_bit = bits_vec[ent_bits + i];
174            if expected_bit != actual_bit {
175                return Err(CompatError::InvalidMnemonic(
176                    "checksum mismatch".to_string(),
177                ));
178            }
179        }
180
181        Ok(Mnemonic {
182            words: word_strs.iter().map(|s| s.to_string()).collect(),
183            entropy,
184            language,
185        })
186    }
187
188    /// Check if this mnemonic has a valid checksum.
189    pub fn check(&self) -> bool {
190        let ent_bits = self.entropy.len() * 8;
191        let checksum_bits = ent_bits / 32;
192        let checksum = sha256(&self.entropy);
193
194        // Re-derive expected words from entropy and compare
195        let wordlist = get_wordlist(self.language);
196        let total_bits = ent_bits + checksum_bits;
197
198        for i in 0..(total_bits / 11) {
199            let mut index: u32 = 0;
200            for j in 0..11 {
201                let bit_pos = i * 11 + j;
202                let bit = if bit_pos < ent_bits {
203                    (self.entropy[bit_pos / 8] >> (7 - (bit_pos % 8))) & 1
204                } else {
205                    let cs_pos = bit_pos - ent_bits;
206                    (checksum[cs_pos / 8] >> (7 - (cs_pos % 8))) & 1
207                };
208                index = (index << 1) | bit as u32;
209            }
210            if self.words[i] != wordlist[index as usize] {
211                return false;
212            }
213        }
214
215        true
216    }
217
218    /// Derive a 64-byte seed from this mnemonic using PBKDF2-HMAC-SHA512.
219    ///
220    /// The passphrase is prepended with "mnemonic" as salt per BIP39 spec.
221    /// Uses 2048 iterations.
222    pub fn to_seed(&self, passphrase: &str) -> Vec<u8> {
223        let mnemonic_str = self.to_phrase();
224        let salt = format!("mnemonic{passphrase}");
225        pbkdf2_hmac_sha512(mnemonic_str.as_bytes(), salt.as_bytes(), 2048, 64)
226    }
227
228    /// Get the mnemonic as a space-separated string.
229    ///
230    /// Japanese mnemonics use ideographic space (U+3000).
231    pub fn to_phrase(&self) -> String {
232        let separator = if self.language == Language::Japanese {
233            "\u{3000}"
234        } else {
235            " "
236        };
237        self.words.join(separator)
238    }
239
240    /// Get a reference to the mnemonic words.
241    pub fn words(&self) -> &[String] {
242        &self.words
243    }
244
245    /// Get a reference to the entropy bytes.
246    pub fn entropy(&self) -> &[u8] {
247        &self.entropy
248    }
249}
250
251impl std::fmt::Display for Mnemonic {
252    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253        write!(f, "{}", self.to_phrase())
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    fn hex_to_bytes(hex: &str) -> Vec<u8> {
262        (0..hex.len())
263            .step_by(2)
264            .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
265            .collect()
266    }
267
268    fn bytes_to_hex(bytes: &[u8]) -> String {
269        bytes.iter().map(|b| format!("{b:02x}")).collect()
270    }
271
272    #[derive(serde::Deserialize)]
273    struct TestVector {
274        entropy: String,
275        mnemonic: String,
276        passphrase: String,
277        seed: String,
278    }
279
280    #[derive(serde::Deserialize)]
281    struct TestVectors {
282        vectors: Vec<TestVector>,
283    }
284
285    fn load_vectors() -> TestVectors {
286        let json = include_str!("../../test-vectors/bip39_vectors.json");
287        serde_json::from_str(json).expect("failed to parse BIP39 test vectors")
288    }
289
290    // Test 1: from_entropy with known 128-bit entropy produces correct 12-word mnemonic
291    #[test]
292    fn test_from_entropy_128bit() {
293        let vectors = load_vectors();
294        let v = &vectors.vectors[0]; // 128-bit all zeros
295        let entropy = hex_to_bytes(&v.entropy);
296        let m = Mnemonic::from_entropy(&entropy, Language::English).unwrap();
297        assert_eq!(m.to_string(), v.mnemonic);
298        assert_eq!(m.words().len(), 12);
299    }
300
301    // Test 2: from_entropy with 256-bit entropy produces correct 24-word mnemonic
302    #[test]
303    fn test_from_entropy_256bit() {
304        let vectors = load_vectors();
305        let v = &vectors.vectors[8]; // 256-bit all zeros
306        let entropy = hex_to_bytes(&v.entropy);
307        let m = Mnemonic::from_entropy(&entropy, Language::English).unwrap();
308        assert_eq!(m.to_string(), v.mnemonic);
309        assert_eq!(m.words().len(), 24);
310    }
311
312    // Test 3: to_seed with known mnemonic and "TREZOR" passphrase
313    #[test]
314    fn test_to_seed_with_trezor_passphrase() {
315        let vectors = load_vectors();
316        let v = &vectors.vectors[0];
317        let m = Mnemonic::from_string(&v.mnemonic, Language::English).unwrap();
318        let seed = m.to_seed(&v.passphrase);
319        assert_eq!(bytes_to_hex(&seed), v.seed);
320    }
321
322    // Test 4: to_seed with empty passphrase
323    #[test]
324    fn test_to_seed_empty_passphrase() {
325        // Use the first vector's mnemonic but with empty passphrase
326        let m = Mnemonic::from_string(
327            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
328            Language::English,
329        ).unwrap();
330        let seed = m.to_seed("");
331        // Verify seed is 64 bytes and differs from TREZOR passphrase seed
332        assert_eq!(seed.len(), 64);
333        let trezor_seed = m.to_seed("TREZOR");
334        assert_ne!(seed, trezor_seed);
335    }
336
337    // Test 5: check() validates a correct mnemonic
338    #[test]
339    fn test_check_valid() {
340        let m = Mnemonic::from_string(
341            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
342            Language::English,
343        ).unwrap();
344        assert!(m.check());
345    }
346
347    // Test 6: check() rejects a mnemonic with wrong checksum
348    #[test]
349    fn test_check_invalid_checksum() {
350        // "abandon" x 12 has wrong checksum (last word should be "about")
351        let result = Mnemonic::from_string(
352            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon",
353            Language::English,
354        );
355        assert!(result.is_err());
356    }
357
358    // Test 7: from_random(128) produces valid 12-word mnemonic
359    #[test]
360    fn test_from_random_128() {
361        let m = Mnemonic::from_random(128, Language::English).unwrap();
362        assert_eq!(m.words().len(), 12);
363        assert!(m.check());
364    }
365
366    // Test 8: from_random(256) produces valid 24-word mnemonic
367    #[test]
368    fn test_from_random_256() {
369        let m = Mnemonic::from_random(256, Language::English).unwrap();
370        assert_eq!(m.words().len(), 24);
371        assert!(m.check());
372    }
373
374    // Test 9: from_string parses and to_string re-produces
375    #[test]
376    fn test_from_string_roundtrip() {
377        let mnemonic_str =
378            "legal winner thank year wave sausage worth useful legal winner thank yellow";
379        let m = Mnemonic::from_string(mnemonic_str, Language::English).unwrap();
380        assert_eq!(m.to_string(), mnemonic_str);
381    }
382
383    // Additional: verify all test vectors for entropy-to-mnemonic
384    #[test]
385    fn test_all_vectors_entropy_to_mnemonic() {
386        let vectors = load_vectors();
387        for (i, v) in vectors.vectors.iter().enumerate() {
388            let entropy = hex_to_bytes(&v.entropy);
389            let m = Mnemonic::from_entropy(&entropy, Language::English).unwrap();
390            assert_eq!(m.to_string(), v.mnemonic, "Vector {i} mnemonic mismatch");
391        }
392    }
393
394    // Additional: verify all test vectors for seed derivation
395    #[test]
396    fn test_all_vectors_seed_derivation() {
397        let vectors = load_vectors();
398        for (i, v) in vectors.vectors.iter().enumerate() {
399            let m = Mnemonic::from_string(&v.mnemonic, Language::English).unwrap();
400            let seed = m.to_seed(&v.passphrase);
401            assert_eq!(bytes_to_hex(&seed), v.seed, "Vector {i} seed mismatch");
402        }
403    }
404}