penumbra_sdk_keys/keys/
seed_phrase.rs

1use std::fmt;
2
3use rand_core::{CryptoRng, RngCore};
4use sha2::Digest;
5
6mod words;
7use words::BIP39_WORDS;
8
9pub const NUM_PBKDF2_ROUNDS: u32 = 2048;
10pub const NUM_WORDS_SHORT: usize = 12;
11pub const NUM_WORDS_LONG: usize = 24;
12
13pub const NUM_ENTROPY_BITS_SHORT: usize = 128;
14pub const NUM_ENTROPY_BITS_LONG: usize = 256;
15pub const NUM_BITS_PER_WORD: usize = 11;
16pub const NUM_BITS_PER_BYTE: usize = 8;
17
18/// Allowed seed phrases for a [`SeedPhrase`]. We support either
19/// the minimum or maximum length.
20pub enum SeedPhraseType {
21    /// The minimum length for a BIP39 mnenomic seed phrase is 12 words.
22    MinimumLength,
23    /// The maximum length for a BIP39 mnenomic seed phrase is 24 words.
24    MaximumLength,
25}
26
27impl SeedPhraseType {
28    pub fn from_length(len: usize) -> anyhow::Result<Self> {
29        match len {
30            NUM_WORDS_SHORT => Ok(SeedPhraseType::MinimumLength),
31            NUM_WORDS_LONG => Ok(SeedPhraseType::MaximumLength),
32            _ => Err(anyhow::anyhow!("invalid seed phrase length")),
33        }
34    }
35
36    pub fn from_randomness_length(len: usize) -> anyhow::Result<Self> {
37        match len * NUM_BITS_PER_BYTE {
38            NUM_ENTROPY_BITS_SHORT => Ok(SeedPhraseType::MinimumLength),
39            NUM_ENTROPY_BITS_LONG => Ok(SeedPhraseType::MaximumLength),
40            _ => Err(anyhow::anyhow!("invalid randomness length")),
41        }
42    }
43
44    pub fn num_words(&self) -> usize {
45        match self {
46            SeedPhraseType::MinimumLength => NUM_WORDS_SHORT,
47            SeedPhraseType::MaximumLength => NUM_WORDS_LONG,
48        }
49    }
50
51    pub fn num_checksum_bits(&self) -> usize {
52        match self {
53            SeedPhraseType::MinimumLength => NUM_ENTROPY_BITS_SHORT / 32,
54            SeedPhraseType::MaximumLength => NUM_ENTROPY_BITS_LONG / 32,
55        }
56    }
57
58    pub fn num_entropy_bits(&self) -> usize {
59        match self {
60            SeedPhraseType::MinimumLength => NUM_ENTROPY_BITS_SHORT,
61            SeedPhraseType::MaximumLength => NUM_ENTROPY_BITS_LONG,
62        }
63    }
64
65    pub fn num_total_bits(&self) -> usize {
66        self.num_entropy_bits() + self.num_checksum_bits()
67    }
68}
69
70/// A mnemonic seed phrase. Used to generate [`SpendSeed`]s.
71#[derive(Clone, Debug)]
72pub struct SeedPhrase(pub Vec<String>);
73
74impl SeedPhrase {
75    /// Randomly generates a 24 word BIP39 [`SeedPhrase`].
76    pub fn generate<R: RngCore + CryptoRng>(mut rng: R) -> Self {
77        let mut randomness = [0u8; NUM_ENTROPY_BITS_LONG / NUM_BITS_PER_BYTE];
78        rng.fill_bytes(&mut randomness);
79        Self::from_randomness(&randomness)
80    }
81
82    /// Randomly generates a 12 word BIP39 [`SeedPhrase`].
83    pub fn short_generate<R: RngCore + CryptoRng>(mut rng: R) -> Self {
84        let mut randomness = [0u8; NUM_ENTROPY_BITS_SHORT / NUM_BITS_PER_BYTE];
85        rng.fill_bytes(&mut randomness);
86        Self::from_randomness(&randomness)
87    }
88
89    /// Given bytes of randomness, generate a [`SeedPhrase`].
90    pub fn from_randomness(randomness: &[u8]) -> Self {
91        // We infer if the seed phrase will be a valid length based on the number of
92        // random bytes generated.
93        let seed_phrase_type = SeedPhraseType::from_randomness_length(randomness.len())
94            .expect("can get seed phrase type from randomness length");
95
96        let num_entropy_bits = seed_phrase_type.num_entropy_bits();
97        let mut bits = vec![false; seed_phrase_type.num_total_bits()];
98        for (i, bit) in bits[0..num_entropy_bits].iter_mut().enumerate() {
99            *bit = (randomness[i / NUM_BITS_PER_BYTE] & (1 << (7 - (i % NUM_BITS_PER_BYTE)))) > 0
100        }
101
102        // We take the first (entropy length in bits)/32 of the SHA256 hash of the randomness and
103        // treat it as a checksum. We append that checksum byte to the initial randomness.
104        //
105        // For 24-word seed phrases, the entropy length in bits is 256 (= 32 * 8), so the checksum
106        // is 8 bits. For 12-word seed phrases, the entropy length in bits is 128 (= 16 * 8), so the
107        // checksum is 4 bits.
108        let mut hasher = sha2::Sha256::new();
109        hasher.update(randomness);
110        let checksum = hasher.finalize()[0];
111        for (i, bit) in bits[num_entropy_bits..].iter_mut().enumerate() {
112            *bit = (checksum & (1 << (7 - (i % NUM_BITS_PER_BYTE)))) > 0
113        }
114
115        // Concatenated bits are split into groups of 11 bits, each
116        // encoding a number that is an index into the BIP39 word list.
117        let mut words = vec![String::new(); seed_phrase_type.num_words()];
118        for (i, word) in words.iter_mut().enumerate() {
119            let bits_this_word = &bits[i * NUM_BITS_PER_WORD..(i + 1) * NUM_BITS_PER_WORD];
120            let word_index = convert_bits_to_usize(bits_this_word);
121            *word = BIP39_WORDS[word_index].to_string();
122        }
123        SeedPhrase(words)
124    }
125
126    /// Number of words in this [`SeedPhrase`].
127    pub fn length(&self) -> usize {
128        self.0.len()
129    }
130
131    /// Verify the checksum of this [`SeedPhrase`].
132    fn verify_checksum(&self) -> anyhow::Result<()> {
133        let seed_phrase_type = SeedPhraseType::from_length(self.length())?;
134        let mut bits = vec![false; seed_phrase_type.num_total_bits()];
135        for (i, word) in self.0.iter().enumerate() {
136            if !BIP39_WORDS.contains(&word.as_str()) {
137                anyhow::bail!("invalid word in BIP39 seed phrase");
138            }
139
140            let word_index = BIP39_WORDS
141                .iter()
142                .position(|&x| x == word)
143                .expect("can get index of word");
144            let word_bits = &mut bits[i * NUM_BITS_PER_WORD..(i + 1) * NUM_BITS_PER_WORD];
145            word_bits
146                .iter_mut()
147                .enumerate()
148                .for_each(|(j, bit)| *bit = (word_index >> (NUM_BITS_PER_WORD - 1 - j)) & 1 == 1);
149        }
150
151        let mut randomness = vec![0u8; seed_phrase_type.num_entropy_bits() / NUM_BITS_PER_BYTE];
152        for (i, random_byte) in randomness.iter_mut().enumerate() {
153            let bits_this_byte = &bits[i * NUM_BITS_PER_BYTE..(i + 1) * NUM_BITS_PER_BYTE];
154            *random_byte = convert_bits_to_usize(bits_this_byte) as u8;
155        }
156
157        let mut hasher = sha2::Sha256::new();
158        hasher.update(randomness);
159        let calculated_checksum = hasher.finalize()[0];
160
161        let mut calculated_checksum_bits = vec![false; seed_phrase_type.num_checksum_bits()];
162        for (i, bit) in calculated_checksum_bits.iter_mut().enumerate() {
163            *bit = (calculated_checksum & (1 << (7 - (i % NUM_BITS_PER_BYTE)))) > 0
164        }
165
166        let checksum_bits = &bits[seed_phrase_type.num_entropy_bits()..];
167        for (expected_bit, checksum_bit) in checksum_bits.iter().zip(calculated_checksum_bits) {
168            if checksum_bit != *expected_bit {
169                return Err(anyhow::anyhow!("seed phrase checksum did not validate"));
170            }
171        }
172        Ok(())
173    }
174}
175
176impl fmt::Display for SeedPhrase {
177    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
178        for (i, word) in self.0.iter().enumerate() {
179            if i > 0 {
180                f.write_str(" ")?;
181            }
182            f.write_str(word)?;
183        }
184        Ok(())
185    }
186}
187
188impl std::str::FromStr for SeedPhrase {
189    type Err = anyhow::Error;
190
191    fn from_str(s: &str) -> Result<Self, Self::Err> {
192        let words = s
193            .split_whitespace()
194            .map(|w| w.to_lowercase())
195            .collect::<Vec<String>>();
196
197        if words.len() != NUM_WORDS_LONG && words.len() != NUM_WORDS_SHORT {
198            anyhow::bail!(
199                "seed phrases should have {} or {} words",
200                NUM_WORDS_LONG,
201                NUM_WORDS_SHORT
202            );
203        }
204
205        let seed_phrase = SeedPhrase(words);
206        seed_phrase.verify_checksum()?;
207
208        Ok(seed_phrase)
209    }
210}
211
212fn convert_bits_to_usize(bits: &[bool]) -> usize {
213    bits.iter()
214        .enumerate()
215        .map(|(i, bit)| if *bit { 1 << (bits.len() - 1 - i) } else { 0 })
216        .sum::<usize>()
217}
218
219#[cfg(test)]
220mod tests {
221    use hmac::Hmac;
222    use pbkdf2::pbkdf2;
223    use std::str::FromStr;
224
225    use super::*;
226
227    #[test]
228    fn bip39_mnemonic_derivation() {
229        // These test vectors are taken from: https://github.com/trezor/python-mnemonic/blob/master/vectors.json
230        let randomness_arr = [
231            "00000000000000000000000000000000",
232            "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
233            "80808080808080808080808080808080",
234            "ffffffffffffffffffffffffffffffff",
235            "9e885d952ad362caeb4efe34a8e91bd2",
236            "c0ba5a8e914111210f2bd131f3d5e08d",
237            "23db8160a31d3e0dca3688ed941adbf3",
238            "f30f8c1da665478f49b001d94c5fc452",
239            "0000000000000000000000000000000000000000000000000000000000000000",
240            "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
241            "8080808080808080808080808080808080808080808080808080808080808080",
242            "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
243            "68a79eaca2324873eacc50cb9c6eca8cc68ea5d936f98787c60c7ebc74e6ce7c",
244            "9f6a2878b2520799a44ef18bc7df394e7061a224d2c33cd015b157d746869863",
245            "066dca1a2bb7e8a1db2832148ce9933eea0f3ac9548d793112d9a95c9407efad",
246            "f585c11aec520db57dd353c69554b21a89b20fb0650966fa0a9d6f74fd989d8f",
247        ];
248        let expected_phrase_arr = [
249            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
250            "legal winner thank year wave sausage worth useful legal winner thank yellow",
251            "letter advice cage absurd amount doctor acoustic avoid letter advice cage above",
252            "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong",
253            "ozone drill grab fiber curtain grace pudding thank cruise elder eight picnic",
254            "scheme spot photo card baby mountain device kick cradle pact join borrow",
255            "cat swing flag economy stadium alone churn speed unique patch report train",
256            "vessel ladder alter error federal sibling chat ability sun glass valve picture",
257            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art",
258            "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title",
259            "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless",
260            "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote",
261            "hamster diagram private dutch cause delay private meat slide toddler razor book happy fancy gospel tennis maple dilemma loan word shrug inflict delay length",
262            "panda eyebrow bullet gorilla call smoke muffin taste mesh discover soft ostrich alcohol speed nation flash devote level hobby quick inner drive ghost inside",
263            "all hour make first leader extend hole alien behind guard gospel lava path output census museum junior mass reopen famous sing advance salt reform",
264            "void come effort suffer camp survey warrior heavy shoot primary clutch crush open amazing screen patrol group space point ten exist slush involve unfold",
265        ];
266        let seed_result_arr = [
267            "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04",
268            "2e8905819b8723fe2c1d161860e5ee1830318dbf49a83bd451cfb8440c28bd6fa457fe1296106559a3c80937a1c1069be3a3a5bd381ee6260e8d9739fce1f607",
269            "d71de856f81a8acc65e6fc851a38d4d7ec216fd0796d0a6827a3ad6ed5511a30fa280f12eb2e47ed2ac03b5c462a0358d18d69fe4f985ec81778c1b370b652a8",
270            "ac27495480225222079d7be181583751e86f571027b0497b5b5d11218e0a8a13332572917f0f8e5a589620c6f15b11c61dee327651a14c34e18231052e48c069",
271            "274ddc525802f7c828d8ef7ddbcdc5304e87ac3535913611fbbfa986d0c9e5476c91689f9c8a54fd55bd38606aa6a8595ad213d4c9c9f9aca3fb217069a41028",
272            "ea725895aaae8d4c1cf682c1bfd2d358d52ed9f0f0591131b559e2724bb234fca05aa9c02c57407e04ee9dc3b454aa63fbff483a8b11de949624b9f1831a9612",
273            "deb5f45449e615feff5640f2e49f933ff51895de3b4381832b3139941c57b59205a42480c52175b6efcffaa58a2503887c1e8b363a707256bdd2b587b46541f5",
274            "2aaa9242daafcee6aa9d7269f17d4efe271e1b9a529178d7dc139cd18747090bf9d60295d0ce74309a78852a9caadf0af48aae1c6253839624076224374bc63f",
275            "bda85446c68413707090a52022edd26a1c9462295029f2e60cd7c4f2bbd3097170af7a4d73245cafa9c3cca8d561a7c3de6f5d4a10be8ed2a5e608d68f92fcc8",
276            "bc09fca1804f7e69da93c2f2028eb238c227f2e9dda30cd63699232578480a4021b146ad717fbb7e451ce9eb835f43620bf5c514db0f8add49f5d121449d3e87",
277            "c0c519bd0e91a2ed54357d9d1ebef6f5af218a153624cf4f2da911a0ed8f7a09e2ef61af0aca007096df430022f7a2b6fb91661a9589097069720d015e4e982f",
278            "dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e1613912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad",
279            "64c87cde7e12ecf6704ab95bb1408bef047c22db4cc7491c4271d170a1b213d20b385bc1588d9c7b38f1b39d415665b8a9030c9ec653d75e65f847d8fc1fc440",
280            "72be8e052fc4919d2adf28d5306b5474b0069df35b02303de8c1729c9538dbb6fc2d731d5f832193cd9fb6aeecbc469594a70e3dd50811b5067f3b88b28c3e8d",
281            "26e975ec644423f4a4c4f4215ef09b4bd7ef924e85d1d17c4cf3f136c2863cf6df0a475045652c57eb5fb41513ca2a2d67722b77e954b4b3fc11f7590449191d",
282            "01f5bced59dec48e362f2c45b5de68b9fd6c92c6634f44d6d40aab69056506f0e35524a518034ddc1192e1dacd32c1ed3eaa3c3b131c88ed8e7e54c49a5d0998",
283        ];
284
285        for (i, (hex_randomness, expected_phrase)) in randomness_arr
286            .iter()
287            .zip(expected_phrase_arr.iter())
288            .enumerate()
289        {
290            let randomness = hex::decode(hex_randomness).expect("can decode test vector");
291            let actual_phrase = SeedPhrase::from_randomness(&randomness[..]);
292            assert_eq!(actual_phrase.to_string(), *expected_phrase);
293            actual_phrase
294                .verify_checksum()
295                .expect("checksum should validate");
296
297            let password = format!("{actual_phrase}");
298            let mut seed_bytes = [0u8; 64];
299            pbkdf2::<Hmac<sha2::Sha512>>(
300                password.as_bytes(),
301                "mnemonicTREZOR".as_bytes(),
302                NUM_PBKDF2_ROUNDS,
303                &mut seed_bytes,
304            )
305            .expect("seed phrase hash always succeeds");
306            let seed_result = hex::encode(seed_bytes);
307            assert_eq!(seed_result, seed_result_arr[i]);
308        }
309    }
310
311    #[test]
312    fn seed_phrase_from_str() {
313        let invalid_phrases = [
314            "too short",
315            "zoo zoooooooo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote", // Invalid word
316            "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth vote", // Invalid checksum
317        ];
318        for phrase in invalid_phrases {
319            assert!(SeedPhrase::from_str(phrase).is_err());
320        }
321
322        let valid_phrases = [
323            "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote",
324            "ZOO zoo ZOO zoo ZOO zoo ZOO zoo ZOO zoo ZOO zoo ZOO zoo ZOO zoo ZOO zoo ZOO zoo ZOO zoo ZOO VOTE",
325            "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title"
326        ];
327        for phrase in valid_phrases {
328            assert!(SeedPhrase::from_str(phrase).is_ok());
329        }
330    }
331}