1use bip39::{Language, Mnemonic};
13use thiserror::Error;
14
15#[derive(Debug, Error)]
16pub enum RecoveryError {
17 #[error("recovery code must be 24 words, got {0}")]
18 WrongWordCount(usize),
19 #[error("recovery code checksum failed — check for typos")]
20 BadChecksum,
21 #[error("unrecognized word in recovery code: {0}")]
22 UnknownWord(String),
23 #[error("invalid recovery code: {0}")]
24 Invalid(String),
25}
26
27pub fn key_to_words(key: &[u8; 32]) -> String {
30 Mnemonic::from_entropy_in(Language::English, key)
31 .expect("32 bytes is always valid BIP39 entropy")
32 .to_string()
33}
34
35pub fn words_to_key(phrase: &str) -> Result<[u8; 32], RecoveryError> {
40 let normalized = phrase
41 .split_whitespace()
42 .map(|w| w.to_lowercase())
43 .collect::<Vec<_>>()
44 .join(" ");
45 let word_count = normalized.split_whitespace().count();
46 if word_count != 24 {
47 return Err(RecoveryError::WrongWordCount(word_count));
48 }
49 let mnemonic = Mnemonic::parse_in_normalized(Language::English, &normalized).map_err(|e| {
50 use bip39::Error as BE;
51 match e {
52 BE::InvalidChecksum => RecoveryError::BadChecksum,
53 BE::UnknownWord(idx) => {
54 let word = normalized
55 .split_whitespace()
56 .nth(idx)
57 .unwrap_or("?")
58 .to_string();
59 RecoveryError::UnknownWord(word)
60 }
61 other => RecoveryError::Invalid(other.to_string()),
62 }
63 })?;
64 let (entropy_bytes, entropy_len) = mnemonic.to_entropy_array();
65 if entropy_len != 32 {
66 return Err(RecoveryError::Invalid(format!(
67 "expected 32 bytes of entropy, got {}",
68 entropy_len
69 )));
70 }
71 let mut out = [0u8; 32];
72 out.copy_from_slice(&entropy_bytes[..32]);
73 Ok(out)
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79
80 #[test]
81 fn roundtrip_random_key() {
82 let key = crate::crypto::generate_aes_key();
83 let phrase = key_to_words(&key);
84 assert_eq!(phrase.split_whitespace().count(), 24);
85 let decoded = words_to_key(&phrase).expect("roundtrip");
86 assert_eq!(decoded, key);
87 }
88
89 #[test]
90 fn all_zero_key_known_vector() {
91 let key = [0u8; 32];
92 let phrase = key_to_words(&key);
93 assert_eq!(
95 phrase,
96 "abandon abandon abandon abandon abandon abandon abandon abandon \
97 abandon abandon abandon abandon abandon abandon abandon abandon \
98 abandon abandon abandon abandon abandon abandon abandon art"
99 );
100 assert_eq!(words_to_key(&phrase).unwrap(), key);
101 }
102
103 #[test]
104 fn accepts_mixed_case_and_extra_whitespace() {
105 let key = [0u8; 32];
106 let messy = " Abandon ABANDON abandon abandon abandon abandon abandon abandon \
107 abandon abandon abandon abandon abandon abandon abandon abandon \
108 abandon abandon abandon abandon abandon abandon abandon ART ";
109 assert_eq!(words_to_key(messy).unwrap(), key);
110 }
111
112 #[test]
113 fn rejects_wrong_word_count() {
114 let err = words_to_key("abandon abandon abandon").unwrap_err();
115 assert!(matches!(err, RecoveryError::WrongWordCount(3)));
116 }
117
118 #[test]
119 fn rejects_bad_checksum() {
120 let bad = "abandon abandon abandon abandon abandon abandon abandon abandon \
123 abandon abandon abandon abandon abandon abandon abandon abandon \
124 abandon abandon abandon abandon abandon abandon abandon abandon";
125 let err = words_to_key(bad).unwrap_err();
126 assert!(matches!(err, RecoveryError::BadChecksum));
127 }
128
129 #[test]
130 fn rejects_unknown_word() {
131 let bad = "zzzzz abandon abandon abandon abandon abandon abandon abandon \
132 abandon abandon abandon abandon abandon abandon abandon abandon \
133 abandon abandon abandon abandon abandon abandon abandon art";
134 let err = words_to_key(bad).unwrap_err();
135 assert!(matches!(err, RecoveryError::UnknownWord(ref w) if w == "zzzzz"));
136 }
137}