seed15/
phrase.rs

1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3#![deny(unused_must_use)]
4#![deny(unused_mut)]
5
6//! seed implements functions for moving between a seed and a seed phrase. This blog post provides
7//! a full specification for the code presented here:
8//!
9//! <https://blog.sia.tech/a-technical-breakdown-of-mysky-seeds-ba9964505978>
10
11use crate::Seed;
12use anyhow::{bail, Error, Result};
13use dictionary_1024::{index_of_word, word_at_index, words_match};
14use sha2::{Digest, Sha256};
15
16
17/// SEED_ENTROPY_WORDS describes the number of words in a seed phrase that contribute to its
18/// fundamental entropy. These are the first 13 words.
19pub const SEED_ENTROPY_WORDS: usize = 13;
20
21/// SEED_CHECKSUMWORDS describes the number of words in a seed phrase that contribute to the
22/// checksum. The checksum is used to ensure copying errors did not occur if a human is
23/// transcribing a seed manually. There is enough entropy in the checksum that an error can usually
24/// be corrected by brute-force with zero false positives.
25pub const SEED_CHECKSUM_WORDS: usize = 2;
26
27/// seed_to_seed_phrase will convert a seed into a seed phrase.
28pub fn seed_to_seed_phrase(seed: Seed) -> String {
29    // Add the entropy words. We process the seed one bit at a time.
30    let mut phrase: String = "".to_string();
31    let mut current_byte = 0;
32    let mut current_bit = 0;
33    for i in 0..SEED_ENTROPY_WORDS {
34        // All words have 10 bits except the final word, which has 8 bits.
35        let mut bits = 10;
36        if i == SEED_ENTROPY_WORDS - 1 {
37            bits = 8;
38        }
39
40        // Iterate over each bit in the next word.
41        let mut word_index: usize = 0;
42        for j in 0..bits {
43            // set the bit in the word_index if it is set in the seed.
44            let bit_is_set = (seed[current_byte] & (1 << (8 - current_bit - 1))) > 0;
45            if bit_is_set {
46                word_index |= 1 << (bits - j - 1);
47            }
48
49            // move on to the next bit.
50            current_bit += 1;
51            if current_bit == 8 {
52                current_bit = 0;
53                current_byte += 1;
54            }
55        }
56
57        // Look up the word and add it to the phrase.
58        if i != 0 {
59            phrase += " ";
60        }
61        phrase += &word_at_index(word_index);
62    }
63
64    // Add the checksum words.
65    let checksum_words = seed_to_checksum_words(seed);
66    phrase += " ";
67    phrase += &checksum_words[0];
68    phrase += " ";
69    phrase += &checksum_words[1];
70    phrase
71}
72
73/// seed_phrase_to_seed converts a seed phrase to a Uint8Array
74pub fn seed_phrase_to_seed(phrase: &str) -> Result<Seed, Error> {
75    // Break the phrase into its component words
76    let all_words: Vec<&str> = phrase.split(' ').collect();
77    let expected_words = SEED_ENTROPY_WORDS + SEED_CHECKSUM_WORDS;
78    if all_words.len() != expected_words {
79        bail!(
80            "expecting {} words but got {} words",
81            expected_words,
82            all_words.len()
83        );
84    }
85
86    // Build the seed from the entropy words. We build the seed out one bit at a time. We convert
87    // the word into a set of entropy bits, then iterate over the bits and add them to the seed.
88    let mut seed: Seed = [0u8; 16];
89    let mut current_byte = 0;
90    let mut current_bit = 0;
91    for i in 0..SEED_ENTROPY_WORDS {
92        let word_index = index_of_word(all_words[i])?;
93
94        // Pack the bits into the seed.
95        let mut bits = 10;
96        if i == SEED_ENTROPY_WORDS - 1 {
97            bits = 8;
98            if word_index > 255 {
99                bail!(
100                    "seed phrase is not valid: {} cannot be the 13th word prefix",
101                    &all_words[SEED_ENTROPY_WORDS - 1]
102                );
103            }
104        }
105        for j in 0..bits {
106            // Set the current bit if needed.
107            let bit_is_set = (word_index & (1 << (bits - j - 1))) > 0;
108            if bit_is_set {
109                seed[current_byte] |= 1 << (8 - current_bit - 1);
110            }
111
112            // Move on to the next bit.
113            current_bit += 1;
114            if current_bit == 8 {
115                current_bit = 0;
116                current_byte += 1;
117            }
118        }
119    }
120
121    // Verify the checksum on the seed.
122    let checksum_words = seed_to_checksum_words(seed);
123    if !words_match(&checksum_words[0], all_words[SEED_ENTROPY_WORDS]) {
124        bail!(
125            "first checksum word is incorrect, expecting prefix {} but got {}",
126            checksum_words[0],
127            all_words[SEED_ENTROPY_WORDS]
128        );
129    }
130    if !words_match(&checksum_words[1], all_words[SEED_ENTROPY_WORDS + 1]) {
131        bail!(
132            "second checksum word is incorrect, expecting prefix {} but got {}",
133            checksum_words[1],
134            all_words[SEED_ENTROPY_WORDS + 1]
135        );
136    }
137
138    // Success.
139    Ok(seed)
140}
141
142/// seed_to_checksum_words will provide the checksum words for a given seed.
143fn seed_to_checksum_words(seed: Seed) -> [String; SEED_CHECKSUM_WORDS] {
144    // Hash the seed to get the checksum entropy.
145    let mut hasher = Sha256::new();
146    hasher.update(&seed);
147    let r = hasher.finalize();
148    let mut result = [0u8; 32];
149    result.copy_from_slice(&r);
150
151    // Convert the first 20 bits of the entropy into two words.
152    let mut word1: usize = (result[0] as usize) << 8;
153    word1 += result[1] as usize;
154    word1 >>= 6;
155    let mut word2: usize = (result[1] as usize) << 10;
156    word2 &= 0xffff;
157    word2 += (result[2] as usize) << 2;
158    word2 >>= 6;
159    [word_at_index(word1), word_at_index(word2)]
160}
161
162/// valid_seed_phrase will return an error if the seed phrase is not valid.
163pub fn valid_seed_phrase(phrase: &str) -> Result<(), Error> {
164    match seed_phrase_to_seed(phrase) {
165        Ok(_) => Ok(()),
166        Err(e) => bail!("seed phrase invalid: {}", e),
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::random_seed;
174
175    // verify_conversion will convert a given seed into a phrase and then back into a seed,
176    // confirming that the new seed has its original value.
177    fn verify_conversion(seed: Seed) {
178        let phrase = seed_to_seed_phrase(seed);
179        println!("{}", phrase);
180        valid_seed_phrase(&phrase).unwrap();
181        let seed_conf = match seed_phrase_to_seed(&phrase) {
182            Ok(s) => s,
183            Err(e) => panic!("verify_conversion failed: {}\n\t{:?}", e, seed),
184        };
185        if seed != seed_conf {
186            panic!(
187                "seed conversion failed: \n\t{:?}\n\t{:?}\n\t{}",
188                seed, seed_conf, phrase
189            );
190        }
191    }
192
193    #[test]
194    // Verify that each of these bad seeds results in an error.
195    fn check_unhappy_seeds() {
196        let good_seed = random_seed();
197        let good_phrase = seed_to_seed_phrase(good_seed);
198
199        // Explore a bad checksum.
200        let mut phrase_words: Vec<&str> = good_phrase.split(" ").collect();
201        let wai0 = word_at_index(0);
202        let wai1 = word_at_index(1);
203        let wai2 = word_at_index(2);
204        phrase_words[0] = &wai0;
205        phrase_words[1] = &wai1;
206        phrase_words[2] = &wai2;
207        let bad_phrase = phrase_words.join(" ");
208        valid_seed_phrase(&bad_phrase).unwrap_err();
209
210        // Explore a malformed word.
211        let mut phrase_words: Vec<&str> = good_phrase.split(" ").collect();
212        phrase_words[0] = "ab";
213        let bad_phrase = phrase_words.join(" ");
214        valid_seed_phrase(&bad_phrase).unwrap_err();
215
216        // Explore just a bad checksum.
217        let mut phrase_words: Vec<&str> = good_phrase.split(" ").collect();
218        if phrase_words[14] == word_at_index(0) {
219            phrase_words[14] = &wai1;
220        } else {
221            phrase_words[14] = &wai0;
222        }
223        let bad_phrase = phrase_words.join(" ");
224        valid_seed_phrase(&bad_phrase).unwrap_err();
225
226        // Explore a missing word.
227        let mut phrase_words: Vec<&str> = good_phrase.split(" ").collect();
228        phrase_words[0] = "abx";
229        let bad_phrase = phrase_words.join(" ");
230        valid_seed_phrase(&bad_phrase).unwrap_err();
231
232        // Explore adding an extra word.
233        let mut phrase_words: Vec<&str> = good_phrase.split(" ").collect();
234        phrase_words.push(&wai0);
235        let bad_phrase = phrase_words.join(" ");
236        valid_seed_phrase(&bad_phrase).unwrap_err();
237
238        // Explore removing a word.
239        let phrase_words: Vec<&str> = good_phrase.split(" ").collect();
240        let bad_phrase = phrase_words[..14].join(" ");
241        valid_seed_phrase(&bad_phrase).unwrap_err();
242    }
243
244    #[test]
245    // perform a basic test to see that a seed can be generated, converted into a seed phrase, and
246    // then converted back.
247    fn check_seed_phrases() {
248        // Try performing some generic seed phrase conversions.
249        let mut seed = [0u8; 16];
250        verify_conversion(seed);
251        seed[0] = 185;
252        verify_conversion(seed);
253        seed[1] = 46;
254        verify_conversion(seed);
255        seed[2] = 7;
256        verify_conversion(seed);
257        seed[3] = 1;
258        verify_conversion(seed);
259        seed[4] = 254;
260        verify_conversion(seed);
261        seed[5] = 2;
262        verify_conversion(seed);
263
264        // Try with 1000 random seeds.
265        for _ in 0..1000 {
266            let seed = random_seed();
267            verify_conversion(seed);
268        }
269
270        // Test a seed where the final entropy word is using a strategically chosen value such that
271        // it is incorrect, but the first 8 bits are still valid and will pass the checksum if the
272        // bounds check is not deliberate. Run the test 1000 times.
273        for _ in 0..1000 {
274            let seed = random_seed();
275            let phrase = seed_to_seed_phrase(seed);
276            let mut words: Vec<&str> = phrase.split(" ").collect();
277
278            // Find the index of the 13th word.
279            let word_index = index_of_word(words[12]).unwrap();
280            if word_index > 255 {
281                panic!("seed generated randomly with 13th word out of bounds");
282            }
283            // Add the extra bit and check for a valid seed.
284            let wai = word_at_index(word_index + 256);
285            words[12] = &wai;
286            let mut altered_phrase = words[0].to_string();
287            for i in 1..words.len() {
288                altered_phrase += " ";
289                altered_phrase += words[i];
290            }
291            match valid_seed_phrase(&altered_phrase) {
292                Ok(()) => panic!("phrase should not be valid after manipulation"),
293                Err(_) => {}
294            };
295        }
296    }
297}