Skip to main content

rite_model/
bip39.rs

1//! BIP-39: bytes as words from the English list, as a hardware wallet
2//! shows its recovery phrase and takes it back.
3//!
4//! The bytes are the wallet's entropy, 16 to 32 bytes in steps of 4. The
5//! first byte of their SHA-256 digest supplies a checksum of one bit per
6//! 32 bits of entropy, appended to them; the result is read eleven bits at
7//! a time, most significant first, each an index into the 2048 words of
8//! the English list. 32 bytes are 24 words, 16 bytes are 12.
9//!
10//! The words, not the entropy, are what a wallet derives its keys from,
11//! so the same entropy in another language's list is a different wallet.
12//! This module knows the English list only, which is the one hardware
13//! wallets use.
14//!
15//! The decoder takes either case and the first four letters of a word,
16//! which the list keeps unique and which metal backup plates carry. A word
17//! it does not find, a count that is not a phrase and a checksum that does
18//! not match are refused, each by the word's position and never by the
19//! word, since the words are a secret.
20
21use std::fmt;
22use std::sync::LazyLock;
23
24use sha2::{Digest, Sha256};
25use zeroize::Zeroizing;
26
27/// The English list, one word per line, in index order.
28const ENGLISH: &str = include_str!("bip39-english.txt");
29
30/// The longest word in the list, in letters.
31pub const WORD_MAX: usize = 8;
32
33/// The words of the English list, in index order.
34static WORDS: LazyLock<Vec<&'static str>> = LazyLock::new(|| ENGLISH.lines().collect());
35
36/// Bits each word carries.
37const WORD_BITS: usize = 11;
38
39/// Words in a phrase for `bytes` bytes of entropy, rounded up for a length
40/// that is not one: three words per four bytes.
41pub fn words_for(bytes: usize) -> usize {
42    bytes.saturating_mul(3).div_ceil(4)
43}
44
45/// Bytes of entropy a phrase of `words` words carries: four per three words.
46pub fn bytes_for(words: usize) -> usize {
47    words.saturating_mul(4) / 3
48}
49
50/// Whether `bytes` bytes of entropy make a phrase: 16 to 32 in steps of 4.
51pub fn is_entropy_len(bytes: usize) -> bool {
52    (16..=32).contains(&bytes) && bytes.is_multiple_of(4)
53}
54
55/// The fewest words a phrase has.
56pub const MIN_WORDS: usize = 12;
57
58/// The most words a phrase has.
59pub const MAX_WORDS: usize = 24;
60
61/// The word counts a phrase may have, as a person reads them.
62pub const PHRASE_LENGTHS: &str = "12, 15, 18, 21 or 24";
63
64/// Whether a phrase may have `words` words: 12 to 24 in steps of 3.
65pub fn is_phrase_len(words: usize) -> bool {
66    (MIN_WORDS..=MAX_WORDS).contains(&words) && words.is_multiple_of(3)
67}
68
69/// The word counts a phrase may have, shortest first.
70pub fn phrase_lengths() -> impl Iterator<Item = usize> {
71    (MIN_WORDS..=MAX_WORDS).filter(|&n| is_phrase_len(n))
72}
73
74/// Write entropy as a phrase, words separated by single spaces.
75///
76/// ```
77/// use rite_model::bip39;
78///
79/// let phrase = bip39::encode(&[0u8; 16]).unwrap();
80/// assert!(phrase.starts_with("abandon abandon"));
81/// assert!(phrase.ends_with("about"));
82/// ```
83///
84/// # Errors
85///
86/// The length, when it is not one BIP-39 has a phrase for.
87pub fn encode(bytes: &[u8]) -> Result<Zeroizing<String>, Bip39Error> {
88    if !is_entropy_len(bytes.len()) {
89        return Err(Bip39Error::Entropy { bytes: bytes.len() });
90    }
91    // The checksum is the leading bits of the digest's first byte, so the
92    // stream is the entropy and that byte, of which the words take only
93    // as many bits as the checksum has.
94    let mut stream = Zeroizing::new(bytes.to_vec());
95    stream.push(checksum(bytes));
96    let words = words_for(bytes.len());
97    let mut phrase = Zeroizing::new(String::with_capacity(words.saturating_mul(WORD_MAX + 1)));
98    for n in 0..words {
99        if n > 0 {
100            phrase.push(' ');
101        }
102        let index = (0..WORD_BITS).fold(0, |index, k| {
103            (index << 1) | u16::from(bit(&stream, n.saturating_mul(WORD_BITS).saturating_add(k)))
104        });
105        phrase.push_str(word(index));
106    }
107    Ok(phrase)
108}
109
110/// Bit `n` of `bytes`, most significant first.
111fn bit(bytes: &[u8], n: usize) -> bool {
112    bytes
113        .get(n / 8)
114        .is_some_and(|&byte| byte & (0x80 >> (n % 8)) != 0)
115}
116
117/// Set bit `n` of `bytes`, most significant first.
118fn set_bit(bytes: &mut [u8], n: usize) {
119    if let Some(byte) = bytes.get_mut(n / 8) {
120        *byte |= 0x80 >> (n % 8);
121    }
122}
123
124/// The word at `index`, which is below 2048 since it has eleven bits.
125fn word(index: u16) -> &'static str {
126    WORDS.get(usize::from(index)).copied().unwrap_or_default()
127}
128
129/// The first byte of the entropy's SHA-256 digest.
130fn checksum(bytes: &[u8]) -> u8 {
131    Sha256::digest(bytes).first().copied().unwrap_or_default()
132}
133
134/// The index of a typed word: the word itself in either case, or its
135/// first four letters or more.
136fn index_of(typed: &str) -> Option<u16> {
137    let typed = Zeroizing::new(typed.to_ascii_lowercase());
138    let found = match WORDS.binary_search(&typed.as_str()) {
139        Ok(i) => Some(i),
140        Err(i) if typed.len() >= 4 => WORDS
141            .get(i)
142            .filter(|w| w.starts_with(typed.as_str()))
143            .map(|_| i),
144        Err(_) => None,
145    };
146    found.and_then(|i| u16::try_from(i).ok())
147}
148
149/// A phrase read back: the entropy, wiped when dropped.
150#[derive(Debug)]
151pub struct Decoded {
152    /// The entropy.
153    pub bytes: Zeroizing<Vec<u8>>,
154}
155
156/// Read a phrase back into its entropy. Words are separated by whitespace.
157///
158/// ```
159/// use rite_model::bip39;
160///
161/// let phrase = bip39::encode(&[0x7F; 16]).unwrap();
162/// assert_eq!(*bip39::decode(&phrase.to_uppercase()).unwrap().bytes, [0x7F; 16]);
163/// // The first four letters of each word are enough.
164/// let short: Vec<String> = phrase.split(' ').map(|w| w.chars().take(4).collect()).collect();
165/// assert_eq!(*bip39::decode(&short.join(" ")).unwrap().bytes, [0x7F; 16]);
166/// ```
167///
168/// # Errors
169///
170/// A word not in the list, a count that is not a phrase, or a checksum
171/// that does not match, each named by position.
172pub fn decode(text: &str) -> Result<Decoded, Bip39Error> {
173    let mut indices = Zeroizing::new(Vec::with_capacity(24));
174    for (position, typed) in text.split_whitespace().enumerate() {
175        let index = index_of(typed).ok_or(Bip39Error::Word {
176            word: position.saturating_add(1),
177        })?;
178        indices.push(index);
179    }
180    if indices.is_empty() {
181        return Err(Bip39Error::Empty);
182    }
183    if !is_phrase_len(indices.len()) {
184        return Err(Bip39Error::Count {
185            words: indices.len(),
186        });
187    }
188    // The entropy, then the checksum's bits at the head of one more byte,
189    // as `encode` lays them out.
190    let length = bytes_for(indices.len());
191    let mut stream = Zeroizing::new(vec![0u8; length.saturating_add(1)]);
192    for (n, &index) in indices.iter().enumerate() {
193        for k in 0..WORD_BITS {
194            if index & (0x400 >> k) != 0 {
195                set_bit(&mut stream, n.saturating_mul(WORD_BITS).saturating_add(k));
196            }
197        }
198    }
199    // The checksum is the leading `length / 4` bits of the byte after the
200    // entropy, compared under a mask of those bits.
201    // 24 words have 8 bits of checksum, the whole byte, which `>>` on a u8
202    // refuses.
203    let bits = u32::try_from(length / 4).unwrap_or(u32::MAX);
204    let mask = !u8::MAX.checked_shr(bits).unwrap_or(0);
205    let expected = checksum(stream.get(..length).unwrap_or(&[])) & mask;
206    if stream
207        .get(length)
208        .is_none_or(|&byte| byte & mask != expected)
209    {
210        return Err(Bip39Error::Checksum);
211    }
212    stream.truncate(length);
213    Ok(Decoded { bytes: stream })
214}
215
216/// One word as typed, for a frontend that takes a phrase a word at a
217/// time: the word it is, in full and lower case. `word` is its position
218/// from 1, for the error to name.
219///
220/// ```
221/// use rite_model::bip39;
222///
223/// assert_eq!(bip39::read_word(" ABAN ", 1).unwrap().as_str(), "abandon");
224/// assert!(bip39::read_word("abandonx", 1).is_err());
225/// assert!(bip39::read_word("abandon ability", 1).is_err());
226/// ```
227///
228/// # Errors
229///
230/// Nothing typed, more than one word, or a word not in the list.
231pub fn read_word(text: &str, word: usize) -> Result<Zeroizing<String>, Bip39Error> {
232    let mut words = text.split_whitespace();
233    let (Some(typed), None) = (words.next(), words.next()) else {
234        return Err(if text.trim().is_empty() {
235            Bip39Error::Empty
236        } else {
237            Bip39Error::OneWord { word }
238        });
239    };
240    let index = index_of(typed).ok_or(Bip39Error::Word { word })?;
241    Ok(Zeroizing::new(self::word(index).to_string()))
242}
243
244/// Why a phrase or a word does not read. None of these names a word.
245#[derive(Debug, Clone, PartialEq, Eq)]
246#[non_exhaustive]
247pub enum Bip39Error {
248    /// Nothing was typed.
249    Empty,
250    /// The word at this position, from 1, is not in the English list.
251    Word {
252        /// Its position.
253        word: usize,
254    },
255    /// A row held more than one word.
256    OneWord {
257        /// The row's position.
258        word: usize,
259    },
260    /// The phrase has a number of words no phrase has.
261    Count {
262        /// How many there were.
263        words: usize,
264    },
265    /// The words do not end in the checksum of the ones before.
266    Checksum,
267    /// Entropy of a length BIP-39 has no phrase for.
268    Entropy {
269        /// Its length.
270        bytes: usize,
271    },
272}
273
274impl fmt::Display for Bip39Error {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        match self {
277            Bip39Error::Empty => f.write_str("nothing to read"),
278            Bip39Error::Word { word } => {
279                write!(f, "word {word} is not in the BIP-39 English list")
280            }
281            Bip39Error::OneWord { word } => {
282                write!(f, "one word per row; row {word} has more")
283            }
284            Bip39Error::Count { words } => {
285                write!(f, "{words} words; a BIP-39 phrase has {PHRASE_LENGTHS}")
286            }
287            Bip39Error::Checksum => f.write_str(
288                "the last word is not the checksum of the others: a word is wrong or out of \
289                 order",
290            ),
291            Bip39Error::Entropy { bytes } => write!(
292                f,
293                "{bytes} bytes; a BIP-39 phrase holds 16 to 32 bytes, in steps of 4"
294            ),
295        }
296    }
297}
298
299impl std::error::Error for Bip39Error {}
300
301#[cfg(test)]
302#[allow(clippy::indexing_slicing)]
303mod tests {
304    use super::*;
305
306    fn hex(s: &str) -> Vec<u8> {
307        base16ct::lower::decode_vec(s).unwrap()
308    }
309
310    /// The English vectors from the BIP-39 reference implementation
311    /// (trezor/python-mnemonic, `vectors.json`), entropy and phrase.
312    const VECTORS: &[(&str, &str)] = &[
313        (
314            "00000000000000000000000000000000",
315            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \
316             abandon about",
317        ),
318        (
319            "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
320            "legal winner thank year wave sausage worth useful legal winner thank yellow",
321        ),
322        (
323            "80808080808080808080808080808080",
324            "letter advice cage absurd amount doctor acoustic avoid letter advice cage above",
325        ),
326        (
327            "ffffffffffffffffffffffffffffffff",
328            "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong",
329        ),
330        (
331            "000000000000000000000000000000000000000000000000",
332            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \
333             abandon abandon abandon abandon abandon abandon abandon agent",
334        ),
335        (
336            "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
337            "legal winner thank year wave sausage worth useful legal winner thank year wave \
338             sausage worth useful legal will",
339        ),
340        (
341            "0000000000000000000000000000000000000000000000000000000000000000",
342            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \
343             abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \
344             abandon abandon abandon art",
345        ),
346        (
347            "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
348            "legal winner thank year wave sausage worth useful legal winner thank year wave \
349             sausage worth useful legal winner thank year wave sausage worth title",
350        ),
351        (
352            "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
353            "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo \
354             zoo zoo zoo vote",
355        ),
356        (
357            "9e885d952ad362caeb4efe34a8e91bd2",
358            "ozone drill grab fiber curtain grace pudding thank cruise elder eight picnic",
359        ),
360        (
361            "6610b25967cdcca9d59875f5cb50b0ea75433311869e930b",
362            "gravity machine north sort system female filter attitude volume fold club stay \
363             feature office ecology stable narrow fog",
364        ),
365        (
366            "68a79eaca2324873eacc50cb9c6eca8cc68ea5d936f98787c60c7ebc74e6ce7c",
367            "hamster diagram private dutch cause delay private meat slide toddler razor book \
368             happy fancy gospel tennis maple dilemma loan word shrug inflict delay length",
369        ),
370        (
371            "f585c11aec520db57dd353c69554b21a89b20fb0650966fa0a9d6f74fd989d8f",
372            "void come effort suffer camp survey warrior heavy shoot primary clutch crush open \
373             amazing screen patrol group space point ten exist slush involve unfold",
374        ),
375    ];
376
377    #[test]
378    fn the_reference_vectors_encode_and_decode() {
379        for (entropy, phrase) in VECTORS {
380            let bytes = hex(entropy);
381            assert_eq!(encode(&bytes).unwrap().as_str(), *phrase, "{entropy}");
382            assert_eq!(*decode(phrase).unwrap().bytes, bytes, "{entropy}");
383        }
384    }
385
386    #[test]
387    fn the_list_is_the_published_one() {
388        assert_eq!(WORDS.len(), 2048);
389        assert_eq!(
390            base16ct::lower::encode_string(&Sha256::digest(ENGLISH.as_bytes())),
391            "2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda"
392        );
393        assert!(WORDS.windows(2).all(|w| w[0] < w[1]));
394        assert_eq!(WORDS.iter().map(|w| w.len()).max(), Some(WORD_MAX));
395    }
396
397    #[test]
398    fn case_and_four_letters_read_through() {
399        let bytes = hex("9e885d952ad362caeb4efe34a8e91bd2");
400        let typed = "OZON Drill\ngrab fibe curt\tgrace pudd thank cruise elder eigh picnic";
401        assert_eq!(*decode(typed).unwrap().bytes, bytes);
402        // Three letters are not enough, even when only one word starts so.
403        assert_eq!(read_word("zoo", 1).unwrap().as_str(), "zoo");
404        assert_eq!(read_word("ozo", 1), Err(Bip39Error::Word { word: 1 }));
405    }
406
407    #[test]
408    fn a_wrong_word_count_or_checksum_is_refused_by_position() {
409        let phrase = VECTORS[9].1;
410        let mut words: Vec<&str> = phrase.split(' ').collect();
411        words[4] = "curtains";
412        assert_eq!(
413            decode(&words.join(" ")).unwrap_err(),
414            Bip39Error::Word { word: 5 }
415        );
416        words[4] = "curtain";
417        words.swap(0, 1);
418        assert_eq!(decode(&words.join(" ")).unwrap_err(), Bip39Error::Checksum);
419        assert_eq!(
420            decode(&words[..11].join(" ")).unwrap_err(),
421            Bip39Error::Count { words: 11 }
422        );
423        assert_eq!(decode("  ").unwrap_err(), Bip39Error::Empty);
424        let refused = decode("abandonx").unwrap_err().to_string();
425        assert!(!refused.contains("abandon"), "{refused}");
426    }
427
428    #[test]
429    fn only_phrase_lengths_encode() {
430        assert_eq!(
431            encode(&[0; 15]).unwrap_err(),
432            Bip39Error::Entropy { bytes: 15 }
433        );
434        assert_eq!(
435            encode(&[0; 36]).unwrap_err(),
436            Bip39Error::Entropy { bytes: 36 }
437        );
438        assert_eq!(words_for(32), 24);
439        assert_eq!(bytes_for(24), 32);
440        assert_eq!(words_for(20), 15);
441        assert!(is_phrase_len(18) && !is_phrase_len(13));
442    }
443}