diff --git a/src/signer.rs b/src/signer.rs index 2943506d..56b76e2c 100644 --- a/src/signer.rs +++ b/src/signer.rs @@ -5,10 +5,16 @@ use crate::random; -use std::{convert::TryInto, error, fmt, str::FromStr}; +use std::{ + convert::TryInto, + error, fmt, fs, + io::{self, Write}, + path, + str::FromStr, +}; use miniscript::bitcoin::{ - self, + self, secp256k1, util::bip32::{self, Error as Bip32Error}, }; @@ -18,6 +24,7 @@ pub enum SignerError { Randomness(random::RandomnessError), Mnemonic(bip39::Error), Bip32(Bip32Error), + MnemonicStorage(io::Error), } impl fmt::Display for SignerError { @@ -26,12 +33,15 @@ impl fmt::Display for SignerError { Self::Randomness(s) => write!(f, "Error related to getting randomness: {}", s), Self::Mnemonic(s) => write!(f, "Error when working with mnemonics: {}", s), Self::Bip32(e) => write!(f, "BIP32 error: {}", e), + Self::MnemonicStorage(e) => write!(f, "BIP39 mnemonic storage error: {}", e), } } } impl error::Error for SignerError {} +pub const MNEMONICS_FOLDER_NAME: &str = "mnemonics"; + // TODO: zeroize, mlock, etc.. For now we don't even encrypt the seed on disk so that'd be // overkill. /// A signer that keeps the key on the laptop. Based on BIP39. @@ -40,6 +50,43 @@ pub struct HotSigner { master_xpriv: bip32::ExtendedPrivKey, } +// TODO: instead of copying them here we could have a util module with those helpers. +// Create a directory with no permission for group and other users. +fn create_dir(path: &path::Path) -> io::Result<()> { + #[cfg(unix)] + return { + use fs::DirBuilder; + use std::os::unix::fs::DirBuilderExt; + + let mut builder = DirBuilder::new(); + builder.mode(0o700).recursive(true).create(path) + }; + + // TODO: permissions on Windows.. + #[cfg(not(unix))] + return { fs::create_dir_all(path) }; +} + +// Create a file with no permission for the group and other users, and only read permissions for +// the current user. +fn create_file(path: &path::Path) -> Result { + let mut options = fs::OpenOptions::new(); + let options = options.read(true).write(true).create_new(true); + + #[cfg(unix)] + return { + use std::os::unix::fs::OpenOptionsExt; + + options.mode(0o400).open(path) + }; + + #[cfg(not(unix))] + return { + // TODO: permissions for Windows... + options.open(path) + }; +} + impl HotSigner { fn from_mnemonic( network: bitcoin::Network, @@ -67,16 +114,90 @@ impl HotSigner { Self::from_mnemonic(network, mnemonic) } + fn mnemonics_folder(datadir_root: &path::Path, network: bitcoin::Network) -> path::PathBuf { + [ + datadir_root, + path::Path::new(&network.to_string()), + path::Path::new(MNEMONICS_FOLDER_NAME), + ] + .iter() + .collect() + } + + /// Read all the mnemonics from the datadir for the given network. + pub fn from_datadir( + datadir_root: &path::Path, + network: bitcoin::Network, + ) -> Result, SignerError> { + let mut signers = Vec::new(); + + let mnemonic_paths = fs::read_dir(Self::mnemonics_folder(datadir_root, network)) + .map_err(SignerError::MnemonicStorage)?; + for entry in mnemonic_paths { + let mnemonic = fs::read_to_string(entry.map_err(SignerError::MnemonicStorage)?.path()) + .map_err(SignerError::MnemonicStorage)?; + signers.push(Self::from_str(network, &mnemonic)?); + } + + Ok(signers) + } + /// The BIP39 mnemonics from which the master key of this signer is derived. pub fn words(&self) -> [&'static str; 12] { let words: Vec<&'static str> = self.mnemonic.word_iter().collect(); words.try_into().expect("Always 12 words") } + + /// The BIP39 mnemonic words as a string. + pub fn mnemonic_str(&self) -> String { + let mut mnemonic_str = String::with_capacity(12 * 7); + let words = self.words(); + + for (i, word) in words.iter().enumerate() { + mnemonic_str += word; + if i < words.len() - 1 { + mnemonic_str += " "; + } + } + + mnemonic_str + } + + /// Store the mnemonic in a file within the given "data directory". + /// The file is stored within a "mnemonics" folder, with the filename set to the fingerprint of + /// the master xpub corresponding to this mnemonic. + pub fn store( + &self, + datadir_root: &path::Path, + network: bitcoin::Network, + secp: &secp256k1::Secp256k1, + ) -> Result<(), SignerError> { + let mut mnemonics_folder = Self::mnemonics_folder(datadir_root, network); + if !mnemonics_folder.exists() { + create_dir(&mnemonics_folder).map_err(SignerError::MnemonicStorage)?; + } + + // This will fail if a file with this fingerprint exists already. + mnemonics_folder.push(format!( + "mnemonic-{:x}.txt", + self.master_xpriv.fingerprint(secp) + )); + let mnemonic_path = mnemonics_folder; + let mut mnemonic_file = + create_file(&mnemonic_path).map_err(SignerError::MnemonicStorage)?; + mnemonic_file + .write_all(self.mnemonic_str().as_bytes()) + .map_err(SignerError::MnemonicStorage)?; + + Ok(()) + } } #[cfg(test)] mod tests { use super::*; + use crate::testutils::*; + use std::collections::HashSet; #[test] fn hot_signer_gen() { @@ -92,11 +213,7 @@ mod tests { // Roundtrips. let signer = HotSigner::generate(bitcoin::Network::Bitcoin).unwrap(); - let mnemonics_str = signer.words().iter().fold(String::new(), |mut s, w| { - s += w; - s += " "; - s - }); + let mnemonics_str = signer.mnemonic_str(); assert_eq!( HotSigner::from_str(bitcoin::Network::Bitcoin, &mnemonics_str) .unwrap() @@ -104,4 +221,28 @@ mod tests { signer.words() ); } + + #[test] + fn hot_signer_storage() { + let secp = secp256k1::Secp256k1::signing_only(); + let tmp_dir = tmp_dir(); + fs::create_dir_all(&tmp_dir).unwrap(); + let network = bitcoin::Network::Bitcoin; + + let words_set: HashSet<_> = (0..10) + .map(|_| { + let signer = HotSigner::generate(network).unwrap(); + signer.store(&tmp_dir, network, &secp).unwrap(); + signer.words() + }) + .collect(); + let words_read: HashSet<_> = HotSigner::from_datadir(&tmp_dir, network) + .unwrap() + .into_iter() + .map(|signer| signer.words()) + .collect(); + assert_eq!(words_set, words_read); + + fs::remove_dir_all(tmp_dir).unwrap(); + } }