1use std::fmt;
22use std::sync::LazyLock;
23
24use sha2::{Digest, Sha256};
25use zeroize::Zeroizing;
26
27const ENGLISH: &str = include_str!("bip39-english.txt");
29
30pub const WORD_MAX: usize = 8;
32
33static WORDS: LazyLock<Vec<&'static str>> = LazyLock::new(|| ENGLISH.lines().collect());
35
36const WORD_BITS: usize = 11;
38
39pub fn words_for(bytes: usize) -> usize {
42 bytes.saturating_mul(3).div_ceil(4)
43}
44
45pub fn bytes_for(words: usize) -> usize {
47 words.saturating_mul(4) / 3
48}
49
50pub fn is_entropy_len(bytes: usize) -> bool {
52 (16..=32).contains(&bytes) && bytes.is_multiple_of(4)
53}
54
55pub const MIN_WORDS: usize = 12;
57
58pub const MAX_WORDS: usize = 24;
60
61pub const PHRASE_LENGTHS: &str = "12, 15, 18, 21 or 24";
63
64pub fn is_phrase_len(words: usize) -> bool {
66 (MIN_WORDS..=MAX_WORDS).contains(&words) && words.is_multiple_of(3)
67}
68
69pub fn phrase_lengths() -> impl Iterator<Item = usize> {
71 (MIN_WORDS..=MAX_WORDS).filter(|&n| is_phrase_len(n))
72}
73
74pub 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 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
110fn bit(bytes: &[u8], n: usize) -> bool {
112 bytes
113 .get(n / 8)
114 .is_some_and(|&byte| byte & (0x80 >> (n % 8)) != 0)
115}
116
117fn 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
124fn word(index: u16) -> &'static str {
126 WORDS.get(usize::from(index)).copied().unwrap_or_default()
127}
128
129fn checksum(bytes: &[u8]) -> u8 {
131 Sha256::digest(bytes).first().copied().unwrap_or_default()
132}
133
134fn 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#[derive(Debug)]
151pub struct Decoded {
152 pub bytes: Zeroizing<Vec<u8>>,
154}
155
156pub 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 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 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
216pub 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#[derive(Debug, Clone, PartialEq, Eq)]
246#[non_exhaustive]
247pub enum Bip39Error {
248 Empty,
250 Word {
252 word: usize,
254 },
255 OneWord {
257 word: usize,
259 },
260 Count {
262 words: usize,
264 },
265 Checksum,
267 Entropy {
269 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 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 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}