pktseed/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use anyhow::{bail, Result};
3use num_bigint::BigUint;
4use num_traits::One;
5use zeroize::Zeroizing;
6
7mod words;
8mod capi;
9
10/// Representation of a wallet seed which can be output as words.
11///
12/// Seed layout:
13/// 
14/// ```
15///     0               1               2               3
16///     0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
17///    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
18///  0 |  U  |  Ver  |E|   Checksum    |           Birthday            |
19///    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
20///  4 |                                                               |
21///    +                                                               +
22///  8 |                                                               |
23///    +                               Seed                            +
24/// 12 |                                                               |
25///    +                                                               +
26/// 16 |                                                               |
27///    +               +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
28/// 20 |               |
29///    +-+-+-+-+-+-+-+-+
30/// ```
31/// * U: unused: Cannot be used because there are only 165 bits in 15 11 bit words. When decoding
32///            the bignum is initialized to 1, which causes unused to be set to 1 so EXPECT_UNUSED
33///            is 1, but after decoding then unused is cleared to zero.
34/// * Ver: 0
35/// * E: 1 if there is a passphrase encrypting the seed, 0 otherwise
36/// * Checksum: first byte of blake2b of structure with Checksum and Unused cleared
37/// * Birthday (encrypted): when the wallet was created, unix time divided by 60*60*24, big endian
38/// * Seed (encrypted): 17 byte seed content
39#[derive(Clone)]
40pub struct SeedEnc {
41    bytes: Zeroizing<[u8; Self::BYTES_LEN]>,
42}
43
44fn nums_for_words(w: &str) -> Result<[u16; SeedEnc::WORD_COUNT]> {
45    let splitwords = w.split(" ").collect::<Vec<_>>();
46    if splitwords.len() != SeedEnc::WORD_COUNT {
47        bail!(
48            "Expected a {} word seed, got {}",
49            SeedEnc::WORD_COUNT,
50            splitwords.len()
51        );
52    }
53    let mut nums = [0_u16; SeedEnc::WORD_COUNT];
54    let mut offending_word = None;
55    for lang in words::LANGUAGES {
56        for (word, i) in splitwords.iter().zip(0..) {
57            if let Some(n) = lang.num_for_word(word) {
58                nums[i] = n;
59                if i == SeedEnc::WORD_COUNT - 1 {
60                    return Ok(nums);
61                }
62            } else {
63                offending_word = Some(word);
64                break;
65            }
66        }
67    }
68    bail!(
69        "No language could be found which matched: {:?} in languages: {:?}",
70        offending_word,
71        words::LANGUAGES.iter().map(|l|l.name).collect::<Vec<_>>()
72    );
73}
74
75
76// I can't believe this is not part of std. No, I'm not pulling in a dep for this.
77fn date_from_ts(ts: u64) -> std::time::SystemTime {
78    use std::ops::Add;
79    std::time::SystemTime::UNIX_EPOCH.add(std::time::Duration::from_secs(ts))
80}
81
82impl SeedEnc {
83    /// July 7th, 2020, the time when this seed algorithm was first released.
84    /// Seeds claiming a birthday older than this should be considered to be almost
85    /// certainly invalid - i.e. the password decryption failed.
86    const BEGINNING_OF_TIME: u64 = 1586276691;
87
88    /// Output the words which represent this seed
89    pub fn words(&self, lang_name: &str) -> Result<String> {
90        if let Some(lang) = words::language(lang_name) {
91            let mut words = [""; Self::WORD_COUNT];
92            for (n, i) in self.nums().iter().zip(0..) {
93                words[i] = lang.word_for_num(*n).unwrap();
94            }
95            Ok(words.join(" "))
96        } else {
97            bail!("Language {} not found", lang_name);
98        }
99    }
100    /// Get a seed from relevant seed words, language is auto-detected
101    pub fn from_words(w: &str) -> Result<Self> {
102        let nums = nums_for_words(w)?;
103        Self::from_nums(nums)
104    }
105    /// Get the unused/unusable part of the seed
106    fn get_unused(&self) -> u8 {
107        self.bytes[0] >> 5
108    }
109    /// Get seed version, only zero currently defined
110    fn get_ver(&self) -> u8 {
111        (self.bytes[0] >> 1) & 0x0f
112    }
113    /// Is the seed encrypted with a passphrase?
114    pub fn is_encrypted(&self) -> bool {
115        self.bytes[0] & 0x01 == 0x01
116    }
117    /// Decrypt the seed into a Seed form. If is_encrypted() is true then
118    /// a passphrase must be specified.
119    /// If force is true then the seed will be decrypted even if the birthday is
120    /// in the future or from before July 7th 2020, when this algorithm was first
121    /// released.
122    pub fn decrypt(&self, passphrase: Option<&[u8]>, force: bool) -> Result<Seed> {
123        let mut copy = self.clone();
124        if passphrase.is_some() && self.is_encrypted() {
125            cipher(&mut copy.bytes[2..], passphrase);
126        } else if self.is_encrypted() {
127            bail!("This seed is encrypted and requires a passphrase to decrypt");
128        }
129        let out = Seed::new_raw(&copy.bytes[2..]);
130        if !force {
131            if out.get_bday() < Self::BEGINNING_OF_TIME {
132                bail!(concat!(
133                    "This seed has a declared birthday of {:?} which is older than the ",
134                    "time when this seed algorithm was first created, the password or ",
135                    "seed words are probably incorrect, to override this message set force ",
136                    "to true."), date_from_ts(out.get_bday()));
137            } else if out.get_bday() > now_sec() {
138                bail!(concat!(
139                    "This seed has a declated birthday of {:?} which is in the future ",
140                    "the seed or password protecting it are probably incorrect. To override ",
141                    "this message set force to true."), date_from_ts(out.get_bday()));
142            }
143        }
144        Ok(Seed::new_raw(&copy.bytes[2..]))
145    }
146
147    ////////////// Internal
148
149    /// Encoded bytes should be this many
150    const BYTES_LEN: usize = 21;
151
152    /// Current (only) version
153    const VER: u8 = 0;
154
155    /// Value of unused should be this, it is an artifact of how we unpack words using bignum.
156    const EXPECT_UNUSED: u8 = 1;
157
158    /// This number of words in the word representation
159    const WORD_COUNT: usize = 15;
160
161    fn from_nums(nums: [u16; Self::WORD_COUNT]) -> Result<Self> {
162        let mut b = BigUint::one();
163        for n in nums.iter().rev() {
164            b <<= 11;
165            b += *n;
166        }
167        let bytes = b.to_bytes_be();
168        if bytes.len() != Self::BYTES_LEN {
169            bail!("invalid seed: unexpected byte length");
170        }
171        let mut out = SeedEnc {
172            bytes: Zeroizing::new([0_u8; Self::BYTES_LEN]),
173        };
174        out.bytes.copy_from_slice(&bytes[..]);
175        if out.get_unused() != Self::EXPECT_UNUSED {
176            bail!("Invalid seed: Wrong bit pattern");
177        }
178        // After unpacking, the unused must be set to zero for checksum
179        out.put_unused(0);
180        if out.get_ver() != Self::VER {
181            bail!("Invalid seed: Unknown version [{}]", out.get_ver())
182        }
183        if out.get_csum() != out.compute_csum() {
184            bail!(
185                "Invalid seed: Checksum mismatch: Declared: [{}], Computed: [{}]",
186                out.get_csum(),
187                out.compute_csum()
188            )
189        }
190        Ok(out)
191    }
192    fn nums(&self) -> [u16; Self::WORD_COUNT] {
193        let mut copy = self.clone();
194        copy.put_unused(Self::EXPECT_UNUSED);
195        let mut b = BigUint::from_bytes_be(&copy.bytes[..]);
196        let mut out = [0_u16; Self::WORD_COUNT];
197        for i in 0..Self::WORD_COUNT {
198            out[i] = (b.iter_u32_digits().next().unwrap() & 2047) as u16;
199            b >>= 11;
200        }
201        assert!(b.is_one());
202        out
203    }
204    fn put_unused(&mut self, u: u8) {
205        self.bytes[0] &= 31;
206        self.bytes[0] |= u << 5;
207    }
208    fn put_ver(&mut self, v: u8) {
209        self.bytes[0] = (self.bytes[0] & 0x01) | ((v & 0x0f) << 1);
210    }
211    fn put_encrypted(&mut self, e: bool) {
212        self.bytes[0] &= 0x1e;
213        if e {
214            self.bytes[0] |= 0x01
215        }
216    }
217    fn get_csum(&self) -> u8 {
218        self.bytes[1]
219    }
220    fn put_csum(&mut self, csum: u8) {
221        self.bytes[1] = csum;
222    }
223    fn compute_csum(&self) -> u8 {
224        let mut b2b = blake2b_simd::Params::new().hash_length(32).to_state();
225        // Compute checksum with checksum byte cleared
226        let mut copy = self.clone();
227        copy.put_csum(0);
228        b2b.update(&copy.bytes[..]);
229        let res = b2b.finalize();
230        res.as_bytes()[0]
231    }
232}
233
234fn now_sec() -> u64 {
235    use std::time::{SystemTime, UNIX_EPOCH};
236    let start = SystemTime::now();
237    start.duration_since(UNIX_EPOCH).unwrap().as_secs()
238}
239
240/// The salt is fixed because:
241/// 1. The password should normally be a strong one
242/// 2. Wallet seeds are something one is unlikely to encounter in large quantity
243/// 3. The resulting seed must be compact
244const ARGON_SALT: &[u8] = b"pktwallet seed 0";
245
246const ARGON_ITERATIONS: u32 = 32;
247const ARGON_THREADS: u32 = 8;
248const ARGON_MEMORY: u32 = 256 * 1024; // 256k
249const ARGON_HASH_LEN: u32 = 19;
250
251fn cipher(data: &mut [u8], passphrase: Option<&[u8]>) {
252    if let Some(passphrase) = passphrase {
253        // let mut hasher = argonautica::Hasher::default();
254        // let output = hasher
255        //     .configure_iterations(ARGON_ITERATIONS)
256        //     .configure_lanes(ARGON_THREADS)
257        //     .configure_memory_size(ARGON_MEMORY)
258        //     .configure_hash_len(ARGON_HASH_LEN)
259        //     .opt_out_of_secret_key(true)
260        //     .with_salt(ARGON_SALT)
261        //     .with_password(passphrase)
262        //     .hash_raw()
263        //     .unwrap();
264        // let hash = output.raw_hash_bytes();
265
266        let hash = argon2::hash_raw(passphrase, ARGON_SALT, &argon2::Config{
267            ad: &[],
268            hash_length: ARGON_HASH_LEN,
269            lanes: ARGON_THREADS,
270            mem_cost: ARGON_MEMORY,
271            secret: &[],
272            thread_mode: argon2::ThreadMode::Parallel,
273            time_cost: ARGON_ITERATIONS,
274            variant: argon2::Variant::Argon2id,
275            version: argon2::Version::Version13,
276        }).unwrap();
277
278        for i in 0..data.len() {
279            data[i] ^= hash[i];
280        }
281    }
282}
283
284/// An internal representation of a wallet seed.
285/// This contains 19 bytes of entropy, 17 of these bytes are pure random data
286/// and two of them represent the seed's "birthday", i.e. the day when the seed
287/// was first created.
288/// By including a "birthday" in the seed, wallet implementations can avoid scanning
289/// the entire history of the blockchain to look for transactions when the wallet may
290/// have been paid. The birthday bytes are also used as entropy.
291#[derive(Clone)]
292pub struct Seed {
293    pub bytes: Zeroizing<[u8; Self::BYTES_LEN]>,
294}
295
296impl Seed {
297    /// Length of the seed bytes
298    pub const BYTES_LEN: usize = 19;
299
300    /// Create new seed, birthday is now, input must be 17 bytes
301    pub fn new(bytes: &[u8]) -> Self {
302        Self::new_bday(bytes, now_sec())
303    }
304    /// Create new seed with birthday specified, input must be 17 bytes
305    pub fn new_bday(bytes: &[u8], birthday: u64) -> Self {
306        let mut out = Self {
307            bytes: Zeroizing::new([0_u8; Self::BYTES_LEN]),
308        };
309        out.bytes[2..].copy_from_slice(bytes);
310        out.put_bday(birthday);
311        out
312    }
313    /// Birthday is included in the bytes, input must be 19 bytes
314    pub fn new_raw(bytes: &[u8]) -> Self {
315        let mut out = Self {
316            bytes: Zeroizing::new([0_u8; Self::BYTES_LEN]),
317        };
318        out.bytes.copy_from_slice(bytes);
319        out
320    }
321    /// Returns unix time seconds since the epoch
322    pub fn get_bday(&self) -> u64 {
323        let mut bday_bytes = [0_u8; 2];
324        bday_bytes.copy_from_slice(&self.bytes[0..2]);
325        let day = u16::from_be_bytes(bday_bytes);
326        (day as u64) * 60 * 60 * 24
327    }
328    /// Set the seed's birthday, unix seconds since the epoch, this is rounded to nearest day
329    pub fn put_bday(&mut self, unix: u64) {
330        let day_bytes = ((unix / (60 * 60 * 24)) as u16).to_be_bytes();
331        self.bytes[0..2].copy_from_slice(&day_bytes[..]);
332    }
333    /// If passphrase is specified then this will encrypt the seed, otherwise just
334    /// copy it into the form from which seed words can be exported.
335    pub fn encrypt(&self, passphrase: Option<&[u8]>) -> SeedEnc {
336        let mut out = SeedEnc {
337            bytes: Zeroizing::new([0_u8; SeedEnc::BYTES_LEN]),
338        };
339        out.bytes[2..].copy_from_slice(&self.bytes[..]);
340        cipher(&mut out.bytes[2..], passphrase);
341        out.put_ver(SeedEnc::VER);
342        out.put_encrypted(passphrase.is_some());
343        out.put_csum(out.compute_csum());
344        out
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::Seed;
351    use super::SeedEnc;
352    use anyhow::Result;
353    const WORDS: &str =
354        "mom blanket bulk draw clip wolf bread erupt merry skin cable infant word exchange animal";
355    const BDAY: u64 = 1629936000;
356    const SECRET_HEX: &str = "49b1ad8001b3c4813d50c087c5a4e206aeb111";
357    const SEED_PASS: &[u8] = b"password";
358
359    #[test]
360    fn test_vec() -> Result<()> {
361        let se = SeedEnc::from_words(WORDS)?;
362        let seed = se.decrypt(Some(SEED_PASS), false)?;
363        let seed_hex = hex::encode(&seed.bytes[..]);
364        assert_eq!(seed.get_bday(), BDAY);
365        assert_eq!(seed_hex, SECRET_HEX);
366        Ok(())
367    }
368
369    #[test]
370    fn test_nums_from_words() -> Result<()> {
371        let expect_nums = [
372            1142_u16, 186, 240, 531, 346, 2022, 219, 615, 1117, 1620, 255, 922, 2027, 629, 72,
373        ];
374        let nums = super::nums_for_words(WORDS)?;
375        assert_eq!(nums, expect_nums);
376        Ok(())
377    }
378
379    #[test]
380    fn test_enc_from_words() -> Result<()> {
381        let se = SeedEnc::from_words(WORDS)?;
382        let seed_hex = hex::encode(&se.bytes[..]);
383        println!("seed_enc_hex = {}", seed_hex);
384        assert_eq!(seed_hex, "01213afeb7343ff2a45d4ce36ff315a4263c05d476");
385        Ok(())
386    }
387
388    #[test]
389    fn test_dec_to_words() {
390        let seed = Seed::new_raw(&hex::decode(SECRET_HEX).unwrap()[..]);
391        let se = seed.encrypt(Some(SEED_PASS));
392        assert_eq!(se.words("english").unwrap(), WORDS);
393    }
394}