From 6e3b951e5452a3cd4a050bde32ee930f6613e601 Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Thu, 19 Jan 2023 14:17:29 +0100 Subject: [PATCH 1/8] signer: a new module with a BIP39-based hot signer --- Cargo.lock | 75 +++++++++++++++++++++++++++++++-- Cargo.toml | 12 ++++++ src/lib.rs | 2 + src/random.rs | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/signer.rs | 84 +++++++++++++++++++++++++++++++++++++ 5 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 src/random.rs create mode 100644 src/signer.rs diff --git a/Cargo.lock b/Cargo.lock index 94585c46..96b341ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,18 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bip39" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e89470017230c38e52b82b3ee3f530db1856ba1d434e3a67a3456a8a8dec5f" +dependencies = [ + "bitcoin_hashes 0.9.7", + "rand_core 0.4.2", + "serde", + "unicode-normalization", +] + [[package]] name = "bitcoin" version = "0.29.1" @@ -71,11 +83,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cb36de3b18ad25f396f9168302e36fb7e1e8923298ab3127da252d288d5af9d" dependencies = [ "bech32", - "bitcoin_hashes", + "bitcoin_hashes 0.11.0", "secp256k1", "serde", ] +[[package]] +name = "bitcoin_hashes" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce18265ec2324ad075345d5814fbeed4f41f0a660055dc78840b74d19b874b1" + [[package]] name = "bitcoin_hashes" version = "0.11.0" @@ -209,12 +227,15 @@ version = "0.2.0" dependencies = [ "backtrace", "base64", + "bip39", "dirs", "fern", + "getrandom", "jsonrpc", "libc", "log", "miniscript", + "rdrand", "rusqlite", "serde", "serde_json", @@ -247,6 +268,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "maybe-uninit" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" + [[package]] name = "memchr" version = "2.5.0" @@ -310,6 +337,30 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rdrand" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e233b642160555c1aa1ff7a78443c6139342f411b6fa6602af2ebbfee9e166bb" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -342,7 +393,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "memchr", - "smallvec", + "smallvec 1.10.0", ] [[package]] @@ -363,7 +414,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7649a0b3ffb32636e60c7ce0d70511eda9c52c658cd0634e194d5a19943aeff" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.11.0", "secp256k1-sys", "serde", ] @@ -408,6 +459,15 @@ dependencies = [ "serde", ] +[[package]] +name = "smallvec" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97fcaeba89edba30f044a10c6a3cc39df9c3f17d7cd829dd1446cab35f890e0" +dependencies = [ + "maybe-uninit", +] + [[package]] name = "smallvec" version = "1.10.0" @@ -460,6 +520,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15c61ba63f9235225a22310255a29b806b907c9b8c964bcbd0a2c70f3f2deea7" +[[package]] +name = "unicode-normalization" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c8070a9942f5e7cfccd93f490fdebd230ee3c3c9f107cb25bad5351ef671cf" +dependencies = [ + "smallvec 0.6.14", +] + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index c76325a7..e7161e75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,3 +56,15 @@ libc = "0.2" # Used for PSBTs base64 = "0.13" + +# Used for generating mnemonics +getrandom = "0.2" + +# Additional entropy for generating mnemonics +[target.'cfg(target_arch = "x86")'.dependencies] +rdrand = "0.8" +[target.'cfg(target_arch = "x86_64")'.dependencies] +rdrand = "0.8" + +# Used for the hot signer +bip39 = "1.0" diff --git a/src/lib.rs b/src/lib.rs index fce5863e..704831c8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,8 @@ mod database; pub mod descriptors; #[cfg(feature = "jsonrpc_server")] mod jsonrpc; +mod random; +pub mod signer; #[cfg(test)] mod testutils; diff --git a/src/random.rs b/src/random.rs new file mode 100644 index 00000000..6b15182d --- /dev/null +++ b/src/random.rs @@ -0,0 +1,112 @@ +use miniscript::bitcoin::hashes::{sha256, Hash, HashEngine}; +use std::{ + collections::hash_map, + error, fmt, + hash::{BuildHasher, Hasher}, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[derive(Debug)] +pub enum RandomnessError { + Hardware(String), + Os(String), + ContextualInfo(String), +} + +impl fmt::Display for RandomnessError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Hardware(s) => write!(f, "Error when getting randomness from hardware: {}", s), + Self::Os(s) => write!(f, "Error when getting randomness from the OS: {}", s), + Self::ContextualInfo(s) => write!(f, "Error when getting contextual info: {}", s), + } + } +} + +impl error::Error for RandomnessError {} + +// Get some entrop from RDRAND when available. +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +fn cpu_randomness() -> Result, RandomnessError> { + if let Ok(mut rand_gen) = rdrand::RdRand::new() { + let mut buf = [0; 32]; + rand_gen + .try_fill_bytes(&mut buf) + .map_err(|e| RandomnessError::Hardware(e.to_string()))?; + assert_ne!(buf, [0; 32]); + Ok(Some(buf)) + } else { + // Not available. + Ok(None) + } +} + +#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] +fn hardware_randomness() -> Result, RandomnessError> { + Ok(None) +} + +// OS-generated randomness. See https://docs.rs/getrandom/latest/getrandom/#supported-targets +// (basically this calls `getrandom()` or polls `/dev/urandom` on Linux, `BCryptGenRandom` on +// Windows, and `getentropy()` / `/dev/random` on Mac. +fn system_randomness() -> Result<[u8; 32], RandomnessError> { + let mut buf = [0; 32]; + getrandom::getrandom(&mut buf).map_err(|e| RandomnessError::Os(e.to_string()))?; + assert_ne!(buf, [0; 32]); + Ok(buf) +} + +// Some more contextual data to try to get at least a slight bit of additional entropy. +fn additional_data() -> Result<[u8; 32], RandomnessError> { + let mut engine = sha256::HashEngine::default(); + + let timestamp: u16 = (SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| RandomnessError::ContextualInfo(e.to_string()))? + .as_secs() + % u16::MAX as u64) as u16; + engine.input(×tamp.to_be_bytes()); + let hasher_number = hash_map::RandomState::new().build_hasher().finish(); + engine.input(&hasher_number.to_be_bytes()); + let pid = std::process::id(); + engine.input(&pid.to_be_bytes()); + // TODO: get some more contextual information + + Ok(*sha256::Hash::from_engine(engine).as_inner()) +} + +/// Get 32 random bytes. This is mainly based on OS-provided randomness (`getrandom` or +/// `/dev/urandom` on Linux, `getentropy` / `/dev/random` on MacOS, and `BCryptGenRandom` on +/// Windows. In addition some randomness may be taken directly from the CPU if it is +/// available, and some contextual information are added to the mix as well. +pub fn random_bytes() -> Result<[u8; 32], RandomnessError> { + let mut engine = sha256::HashEngine::default(); + + if let Some(bytes) = cpu_randomness()? { + engine.input(&bytes); + } + engine.input(&system_randomness()?); + engine.input(&additional_data()?); + // TODO: add more sources of randomness + + Ok(*sha256::Hash::from_engine(engine).as_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + // This does not test the quality of the randomness but at least sanity checks it's + // not obviously broken. + #[test] + fn randomness_sanity_check() { + let mut set = HashSet::with_capacity(100); + + for _ in 0..100 { + let rand = random_bytes().unwrap(); + assert!(!set.contains(&rand)); + set.insert(rand); + } + } +} diff --git a/src/signer.rs b/src/signer.rs new file mode 100644 index 00000000..b342aa71 --- /dev/null +++ b/src/signer.rs @@ -0,0 +1,84 @@ +//! Signer module +//! +//! Some helpers to facilitate the usage of a signer in client of the Liana daemon. For now +//! only contains a hot signer. + +use crate::random; + +use std::{convert::TryInto, error, fmt, str}; + +/// An error related to using a signer. +#[derive(Debug)] +pub enum SignerError { + Randomness(random::RandomnessError), + Mnemonic(bip39::Error), +} + +impl fmt::Display for SignerError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Randomness(s) => write!(f, "Error related to getting randomness: {}", s), + Self::Mnemonic(s) => write!(f, "Error when working with mnemonics: {}", s), + } + } +} + +impl error::Error for SignerError {} + +/// A signer that keeps the key on the laptop. Based on BIP39. +pub struct HotSigner { + mnemonic: bip39::Mnemonic, +} + +impl HotSigner { + /// Create a new hot signer from random bytes. Uses a 12-words mnemonics without a passphrase. + pub fn generate() -> Result { + // We want a 12-words mnemonic so we only use 16 of the 32 bytes. + let random_32bytes = random::random_bytes().map_err(SignerError::Randomness)?; + let mnemonic = + bip39::Mnemonic::from_entropy(&random_32bytes[..16]).map_err(SignerError::Mnemonic)?; + Ok(Self { mnemonic }) + } + + /// 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") + } +} + +impl str::FromStr for HotSigner { + type Err = SignerError; + + fn from_str(s: &str) -> Result { + let mnemonic = bip39::Mnemonic::from_str(s).map_err(SignerError::Mnemonic)?; + Ok(Self { mnemonic }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn hot_signer_gen() { + // Entropy isn't completely broken. + assert_ne!( + HotSigner::generate().unwrap().words(), + HotSigner::generate().unwrap().words() + ); + + // Roundtrips. + let signer = HotSigner::generate().unwrap(); + let mnemonics_str = signer.words().iter().fold(String::new(), |mut s, w| { + s += w; + s += " "; + s + }); + assert_eq!( + HotSigner::from_str(&mnemonics_str).unwrap().words(), + signer.words() + ); + } +} From d341b6dea9d7eb9663eb9a4ef55617340146dc1e Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Thu, 19 Jan 2023 14:47:15 +0100 Subject: [PATCH 2/8] signer: cache the master xpriv --- src/signer.rs | 57 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/src/signer.rs b/src/signer.rs index b342aa71..2943506d 100644 --- a/src/signer.rs +++ b/src/signer.rs @@ -5,13 +5,19 @@ use crate::random; -use std::{convert::TryInto, error, fmt, str}; +use std::{convert::TryInto, error, fmt, str::FromStr}; + +use miniscript::bitcoin::{ + self, + util::bip32::{self, Error as Bip32Error}, +}; /// An error related to using a signer. #[derive(Debug)] pub enum SignerError { Randomness(random::RandomnessError), Mnemonic(bip39::Error), + Bip32(Bip32Error), } impl fmt::Display for SignerError { @@ -19,25 +25,46 @@ impl fmt::Display for SignerError { match self { 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), } } } impl error::Error for SignerError {} +// 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. pub struct HotSigner { mnemonic: bip39::Mnemonic, + master_xpriv: bip32::ExtendedPrivKey, } impl HotSigner { + fn from_mnemonic( + network: bitcoin::Network, + mnemonic: bip39::Mnemonic, + ) -> Result { + let master_xpriv = bip32::ExtendedPrivKey::new_master(network, &mnemonic.to_seed("")) + .map_err(SignerError::Bip32)?; + Ok(Self { + mnemonic, + master_xpriv, + }) + } + /// Create a new hot signer from random bytes. Uses a 12-words mnemonics without a passphrase. - pub fn generate() -> Result { + pub fn generate(network: bitcoin::Network) -> Result { // We want a 12-words mnemonic so we only use 16 of the 32 bytes. let random_32bytes = random::random_bytes().map_err(SignerError::Randomness)?; let mnemonic = bip39::Mnemonic::from_entropy(&random_32bytes[..16]).map_err(SignerError::Mnemonic)?; - Ok(Self { mnemonic }) + Self::from_mnemonic(network, mnemonic) + } + + pub fn from_str(network: bitcoin::Network, s: &str) -> Result { + let mnemonic = bip39::Mnemonic::from_str(s).map_err(SignerError::Mnemonic)?; + Self::from_mnemonic(network, mnemonic) } /// The BIP39 mnemonics from which the master key of this signer is derived. @@ -47,37 +74,33 @@ impl HotSigner { } } -impl str::FromStr for HotSigner { - type Err = SignerError; - - fn from_str(s: &str) -> Result { - let mnemonic = bip39::Mnemonic::from_str(s).map_err(SignerError::Mnemonic)?; - Ok(Self { mnemonic }) - } -} - #[cfg(test)] mod tests { use super::*; - use std::str::FromStr; #[test] fn hot_signer_gen() { // Entropy isn't completely broken. assert_ne!( - HotSigner::generate().unwrap().words(), - HotSigner::generate().unwrap().words() + HotSigner::generate(bitcoin::Network::Bitcoin) + .unwrap() + .words(), + HotSigner::generate(bitcoin::Network::Bitcoin) + .unwrap() + .words() ); // Roundtrips. - let signer = HotSigner::generate().unwrap(); + let signer = HotSigner::generate(bitcoin::Network::Bitcoin).unwrap(); let mnemonics_str = signer.words().iter().fold(String::new(), |mut s, w| { s += w; s += " "; s }); assert_eq!( - HotSigner::from_str(&mnemonics_str).unwrap().words(), + HotSigner::from_str(bitcoin::Network::Bitcoin, &mnemonics_str) + .unwrap() + .words(), signer.words() ); } From 59e55ae9f2c33f4865ff28282325a5cf156ca37e Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Thu, 19 Jan 2023 17:29:31 +0100 Subject: [PATCH 3/8] signer: implement mnemonics storage, and initialization from storage --- src/signer.rs | 155 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 148 insertions(+), 7 deletions(-) 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(); + } } From b88874107e3b45876a63506ab67afbc710cd2a27 Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Fri, 20 Jan 2023 12:34:23 +0100 Subject: [PATCH 4/8] signer: add a method to get the xpub at a given path --- src/signer.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/signer.rs b/src/signer.rs index 56b76e2c..1c377a8a 100644 --- a/src/signer.rs +++ b/src/signer.rs @@ -191,6 +191,19 @@ impl HotSigner { Ok(()) } + + /// Get the extended public key at the given derivation path. + pub fn xpub_at( + &self, + der_path: &bip32::DerivationPath, + secp: &secp256k1::Secp256k1, + ) -> bip32::ExtendedPubKey { + let xpriv = self + .master_xpriv + .derive_priv(secp, der_path) + .expect("Never fails"); + bip32::ExtendedPubKey::from_priv(secp, &xpriv) + } } #[cfg(test)] @@ -220,6 +233,13 @@ mod tests { .words(), signer.words() ); + + // We can get an xpub for it. + let secp = secp256k1::Secp256k1::signing_only(); + let _ = signer.xpub_at( + &bip32::DerivationPath::from_str("m/42'/43/0987'/0/2").unwrap(), + &secp, + ); } #[test] From f5e7632c73abef4f27f5617c877caf8574b3fe3b Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Thu, 26 Jan 2023 21:26:22 +0100 Subject: [PATCH 5/8] signer: expose a method for signing a PSBT --- src/signer.rs | 328 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 316 insertions(+), 12 deletions(-) diff --git a/src/signer.rs b/src/signer.rs index 1c377a8a..7a027b36 100644 --- a/src/signer.rs +++ b/src/signer.rs @@ -14,8 +14,15 @@ use std::{ }; use miniscript::bitcoin::{ - self, secp256k1, - util::bip32::{self, Error as Bip32Error}, + self, + hashes::Hash, + secp256k1, + util::{ + bip32::{self, Error as Bip32Error}, + ecdsa, + psbt::Psbt, + sighash, + }, }; /// An error related to using a signer. @@ -25,6 +32,8 @@ pub enum SignerError { Mnemonic(bip39::Error), Bip32(Bip32Error), MnemonicStorage(io::Error), + InsanePsbt, + IncompletePsbt, } impl fmt::Display for SignerError { @@ -34,6 +43,11 @@ impl fmt::Display for SignerError { 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), + Self::InsanePsbt => write!(f, "Information contained in the PSBT is wrong."), + Self::IncompletePsbt => write!( + f, + "The PSBT is missing some information necessary for signing." + ), } } } @@ -163,6 +177,14 @@ impl HotSigner { mnemonic_str } + /// Get the fingerprint of the master xpub for this signer. + pub fn fingerprint( + &self, + secp: &secp256k1::Secp256k1, + ) -> bip32::Fingerprint { + self.master_xpriv.fingerprint(secp) + } + /// 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. @@ -178,10 +200,7 @@ impl HotSigner { } // This will fail if a file with this fingerprint exists already. - mnemonics_folder.push(format!( - "mnemonic-{:x}.txt", - self.master_xpriv.fingerprint(secp) - )); + mnemonics_folder.push(format!("mnemonic-{:x}.txt", self.fingerprint(secp))); let mnemonic_path = mnemonics_folder; let mut mnemonic_file = create_file(&mnemonic_path).map_err(SignerError::MnemonicStorage)?; @@ -192,25 +211,93 @@ impl HotSigner { Ok(()) } + fn xpriv_at( + &self, + der_path: &bip32::DerivationPath, + secp: &secp256k1::Secp256k1, + ) -> bip32::ExtendedPrivKey { + self.master_xpriv + .derive_priv(secp, der_path) + .expect("Never fails") + } + /// Get the extended public key at the given derivation path. pub fn xpub_at( &self, der_path: &bip32::DerivationPath, secp: &secp256k1::Secp256k1, ) -> bip32::ExtendedPubKey { - let xpriv = self - .master_xpriv - .derive_priv(secp, der_path) - .expect("Never fails"); + let xpriv = self.xpriv_at(der_path, secp); bip32::ExtendedPubKey::from_priv(secp, &xpriv) } + + /// Sign all inputs of the given PSBT. + /// + /// **This does not perform any check. It will blindly sign anything that's passed.** + pub fn sign_psbt( + &self, + mut psbt: Psbt, + secp: &secp256k1::Secp256k1, + ) -> Result { + let master_fingerprint = self.fingerprint(secp); + let mut sighash_cache = sighash::SighashCache::new(&psbt.unsigned_tx); + + // Sign each input in the PSBT. + for i in 0..psbt.inputs.len() { + // First of all compute the sighash for this input. We assume P2WSH spend: the sighash + // script code is always the witness script. + let witscript = psbt.inputs[i] + .witness_script + .as_ref() + .ok_or(SignerError::IncompletePsbt)?; + let value = psbt.inputs[i] + .witness_utxo + .as_ref() + .ok_or(SignerError::IncompletePsbt)? + .value; + let sig_type = sighash::EcdsaSighashType::All; + let sighash = sighash_cache + .segwit_signature_hash(i, witscript, value, sig_type) + .map_err(|_| SignerError::InsanePsbt)?; + let sighash = secp256k1::Message::from_slice(sighash.as_hash().as_inner()) + .expect("Sighash is always 32 bytes."); + + // Then provide a signature for all the keys they asked for. + // FIXME: get rid of this clone somehow.. Can't we just tell the borrow checker it's + // fine? + for (curr_pubkey, (fingerprint, der_path)) in psbt.inputs[i].bip32_derivation.clone() { + if fingerprint != master_fingerprint { + continue; + } + let privkey = self.xpriv_at(&der_path, secp).to_priv(); + let pubkey = privkey.public_key(secp); + if pubkey.inner != curr_pubkey { + return Err(SignerError::InsanePsbt); + } + let sig = secp.sign_ecdsa_low_r(&sighash, &privkey.inner); + psbt.inputs[i].partial_sigs.insert( + pubkey, + ecdsa::EcdsaSig { + sig, + hash_ty: sig_type, + }, + ); + } + } + + Ok(psbt) + } } #[cfg(test)] mod tests { use super::*; - use crate::testutils::*; - use std::collections::HashSet; + use crate::{descriptors, testutils::*}; + use miniscript::{ + bitcoin::util::psbt::Input as PsbtIn, + descriptor::{DerivPaths, DescriptorMultiXKey, DescriptorPublicKey, Wildcard}, + }; + use std::collections::{BTreeMap, HashSet}; #[test] fn hot_signer_gen() { @@ -265,4 +352,221 @@ mod tests { fs::remove_dir_all(tmp_dir).unwrap(); } + + #[test] + fn hot_signer_sign() { + let secp = secp256k1::Secp256k1::new(); + let network = bitcoin::Network::Bitcoin; + + // Create a Liana descriptor with as primary path a 2-of-3 with two hot signers (2 keys are + // on the same signer) and a single hot signer as recovery path. Use various random + // derivation paths. + let (prim_signer_a, prim_signer_b, recov_signer) = ( + HotSigner::generate(network).unwrap(), + HotSigner::generate(network).unwrap(), + HotSigner::generate(network).unwrap(), + ); + let origin_der = bip32::DerivationPath::from_str("m/0'/12'/42").unwrap(); + let xkey = prim_signer_a.xpub_at(&origin_der, &secp); + let prim_key_a = DescriptorPublicKey::MultiXPub(DescriptorMultiXKey { + origin: Some((prim_signer_a.fingerprint(&secp), origin_der)), + xkey, + derivation_paths: DerivPaths::new(vec![ + bip32::DerivationPath::from_str("m/420/56/0").unwrap(), + bip32::DerivationPath::from_str("m/420/56/1").unwrap(), + ]) + .unwrap(), + wildcard: Wildcard::Unhardened, + }); + let origin_der = bip32::DerivationPath::from_str("m/18'/24'").unwrap(); + let xkey = prim_signer_b.xpub_at(&origin_der, &secp); + let prim_key_b = DescriptorPublicKey::MultiXPub(DescriptorMultiXKey { + origin: Some((prim_signer_b.fingerprint(&secp), origin_der)), + xkey, + derivation_paths: DerivPaths::new(vec![ + bip32::DerivationPath::from_str("m/31/0").unwrap(), + bip32::DerivationPath::from_str("m/31/1").unwrap(), + ]) + .unwrap(), + wildcard: Wildcard::Unhardened, + }); + let origin_der = bip32::DerivationPath::from_str("m/18'/25'").unwrap(); + let xkey = prim_signer_b.xpub_at(&origin_der, &secp); + let prim_key_c = DescriptorPublicKey::MultiXPub(DescriptorMultiXKey { + origin: Some((prim_signer_b.fingerprint(&secp), origin_der)), + xkey, + derivation_paths: DerivPaths::new(vec![ + bip32::DerivationPath::from_str("m/0").unwrap(), + bip32::DerivationPath::from_str("m/1").unwrap(), + ]) + .unwrap(), + wildcard: Wildcard::Unhardened, + }); + let prim_keys = + descriptors::LianaDescKeys::from_multi(2, vec![prim_key_a, prim_key_b, prim_key_c]) + .unwrap(); + let origin_der = bip32::DerivationPath::from_str("m/1/2'/3/4'").unwrap(); + let xkey = recov_signer.xpub_at(&origin_der, &secp); + let recov_key = DescriptorPublicKey::MultiXPub(DescriptorMultiXKey { + origin: Some((recov_signer.fingerprint(&secp), origin_der)), + xkey, + derivation_paths: DerivPaths::new(vec![ + bip32::DerivationPath::from_str("m/5/6/0").unwrap(), + bip32::DerivationPath::from_str("m/5/6/1").unwrap(), + ]) + .unwrap(), + wildcard: Wildcard::Unhardened, + }); + let recov_keys = descriptors::LianaDescKeys::from_single(recov_key); + let desc = descriptors::MultipathDescriptor::new(prim_keys, recov_keys, 42).unwrap(); + + // Create a dummy PSBT spending a coin from this descriptor with a single input and single + // (external) output. We'll be modifying it as we go. + let spent_coin_desc = desc.receive_descriptor().derive(42.into(), &secp); + let mut dummy_psbt = Psbt { + unsigned_tx: bitcoin::Transaction { + version: 2, + lock_time: bitcoin::PackedLockTime(0), + input: vec![bitcoin::TxIn { + sequence: bitcoin::Sequence::ENABLE_RBF_NO_LOCKTIME, + previous_output: bitcoin::OutPoint::from_str( + "4613e078e4cdbb0fce1bc6e44b028f0e11621a134a1605efdc456c32d155c922:19", + ) + .unwrap(), + ..bitcoin::TxIn::default() + }], + output: vec![bitcoin::TxOut { + value: 18_420, + script_pubkey: bitcoin::Address::from_str( + "bc1qvklensptw5lk7d470ds60pcpsr0psdpgyvwepv", + ) + .unwrap() + .script_pubkey(), + }], + }, + version: 0, + xpub: BTreeMap::new(), + proprietary: BTreeMap::new(), + unknown: BTreeMap::new(), + inputs: vec![PsbtIn { + witness_script: Some(spent_coin_desc.witness_script()), + bip32_derivation: spent_coin_desc.bip32_derivations(), + witness_utxo: Some(bitcoin::TxOut { + value: 19_000, + script_pubkey: spent_coin_desc.script_pubkey(), + }), + ..PsbtIn::default() + }], + outputs: Vec::new(), + }; + + // Sign the PSBT with the two primary signers. The second signer will sign for the two keys + // that it manages. + // We can also add a signature for the recovery key with the recovery signer. + let psbt = dummy_psbt.clone(); + assert!(psbt.inputs[0].partial_sigs.is_empty()); + let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap(); + assert_eq!(psbt.inputs[0].partial_sigs.len(), 1); + let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap(); + assert_eq!(psbt.inputs[0].partial_sigs.len(), 3); + let psbt = recov_signer.sign_psbt(psbt, &secp).unwrap(); + assert_eq!(psbt.inputs[0].partial_sigs.len(), 4); + + // We can add another external output to the transaction, we can still sign without issue. + // The output can be insane, we don't check it. It doesn't even need an accompanying PSBT + // output. + dummy_psbt + .unsigned_tx + .output + .push(bitcoin::TxOut::default()); + let psbt = dummy_psbt.clone(); + assert!(psbt.inputs[0].partial_sigs.is_empty()); + let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap(); + assert_eq!(psbt.inputs[0].partial_sigs.len(), 1); + let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap(); + assert_eq!(psbt.inputs[0].partial_sigs.len(), 3); + let psbt = recov_signer.sign_psbt(psbt, &secp).unwrap(); + assert_eq!(psbt.inputs[0].partial_sigs.len(), 4); + + // We can add another input to the PSBT. If we don't attach also another transaction input + // it will fail. + let other_spent_coin_desc = desc.receive_descriptor().derive(84.into(), &secp); + dummy_psbt.inputs.push(PsbtIn { + witness_script: Some(other_spent_coin_desc.witness_script()), + bip32_derivation: other_spent_coin_desc.bip32_derivations(), + witness_utxo: Some(bitcoin::TxOut { + value: 19_000, + script_pubkey: other_spent_coin_desc.script_pubkey(), + }), + ..PsbtIn::default() + }); + let psbt = dummy_psbt.clone(); + assert!(prim_signer_a + .sign_psbt(psbt, &secp) + .unwrap_err() + .to_string() + .contains("Information contained in the PSBT is wrong")); + + // But now if we add the inputs also to the transaction itself, it will have signed both + // inputs. + dummy_psbt.unsigned_tx.input.push(bitcoin::TxIn { + // Note the sequence can be different. We don't care. + sequence: bitcoin::Sequence::ENABLE_LOCKTIME_NO_RBF, + previous_output: bitcoin::OutPoint::from_str( + "5613e078e4cdbb0fce1bc6e44b028f0e11621a134a1605efdc456c32d155c922:0", + ) + .unwrap(), + ..bitcoin::TxIn::default() + }); + let psbt = dummy_psbt.clone(); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.partial_sigs.is_empty())); + let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap(); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.partial_sigs.len() == 1)); + let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap(); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.partial_sigs.len() == 3)); + let psbt = recov_signer.sign_psbt(psbt, &secp).unwrap(); + assert!(psbt + .inputs + .iter() + .all(|psbt_in| psbt_in.partial_sigs.len() == 4)); + + // If the witness script is missing for one of the inputs it'll tell us the PSBT is + // incomplete. + let mut psbt = dummy_psbt.clone(); + psbt.inputs[1].witness_script = None; + assert!(prim_signer_a + .sign_psbt(psbt, &secp) + .unwrap_err() + .to_string() + .contains("The PSBT is missing some information necessary for signing.")); + + // If the witness utxo is missing for one of the inputs it'll tell us the PSBT is + // incomplete. + let mut psbt = dummy_psbt.clone(); + psbt.inputs[1].witness_utxo = None; + assert!(prim_signer_a + .sign_psbt(psbt, &secp) + .unwrap_err() + .to_string() + .contains("The PSBT is missing some information necessary for signing.")); + + // If we remove the BIP32 derivations for the first input it will only provide signatures + // for the second one. + let mut psbt = dummy_psbt.clone(); + assert!(psbt.inputs[0].partial_sigs.is_empty()); + assert!(psbt.inputs[1].partial_sigs.is_empty()); + psbt.inputs[0].bip32_derivation.clear(); + let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap(); + assert!(psbt.inputs[0].partial_sigs.is_empty()); + assert_eq!(psbt.inputs[1].partial_sigs.len(), 2); + } } From 50f13d3e2e2802ebbc6efceccb03eafe352b1e77 Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Mon, 13 Feb 2023 17:55:45 +0100 Subject: [PATCH 6/8] signer: allow to the set the network for extended keys encoding --- src/signer.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/signer.rs b/src/signer.rs index 7a027b36..6d415392 100644 --- a/src/signer.rs +++ b/src/signer.rs @@ -287,6 +287,13 @@ impl HotSigner { Ok(psbt) } + + /// Change the network of generated extended keys. Note this value only has to do with the + /// BIP32 encoding of those keys (xpubs, tpubs, ..) but does not affect any data (whether it is + /// the keys or the mnemonics). + pub fn set_network(&mut self, network: bitcoin::Network) { + self.master_xpriv.network = network; + } } #[cfg(test)] @@ -569,4 +576,30 @@ mod tests { assert!(psbt.inputs[0].partial_sigs.is_empty()); assert_eq!(psbt.inputs[1].partial_sigs.len(), 2); } + + #[test] + fn signer_set_net() { + let secp = secp256k1::Secp256k1::signing_only(); + let mut signer = HotSigner::from_str( + bitcoin::Network::Bitcoin, + "burger ball theme dog light account produce chest warrior swarm flip equip", + ) + .unwrap(); + assert_eq!(signer.xpub_at(&bip32::DerivationPath::master(), &secp).to_string(), "xpub661MyMwAqRbcGKvR8dChsA92AHfJS6fJMR41jAASu5S79v65dac244iBd7PwqnfMQ9jWsmg8SqnNz3MjkwYF8Edzr2ttxt171Cr5RyJrvF2"); + + let tpub = "tpubD6NzVbkrYhZ4Y87GapBo55UPVQkxRVAMu3eK5iDbEzBzuCknhoT7CWP1s9UjNHcbC4GRVMBzywcRgDrM9oPV1g6HudeCeQfLbASVBxpNJV3"; + for net in &[ + bitcoin::Network::Testnet, + bitcoin::Network::Signet, + bitcoin::Network::Regtest, + ] { + signer.set_network(*net); + assert_eq!( + signer + .xpub_at(&bip32::DerivationPath::master(), &secp) + .to_string(), + tpub + ); + } + } } From 157eea989322eed2ea9553d3640e884fb657541a Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Tue, 14 Feb 2023 17:39:28 +0100 Subject: [PATCH 7/8] lib: re-export the bip39 dependency --- src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib.rs b/src/lib.rs index 704831c8..8b1cc4c5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod signer; #[cfg(test)] mod testutils; +pub use bip39; pub use miniscript; pub use crate::bitcoin::d::{BitcoindError, WalletError}; From 20f394a452e425f4bdad0195637612499575a878 Mon Sep 17 00:00:00 2001 From: Antoine Poinsot Date: Tue, 14 Feb 2023 17:39:40 +0100 Subject: [PATCH 8/8] random: add a commented-out test i used to run ENT https://www.fourmilab.ch/random/ Entropy = 8.000000 bits per byte. Optimum compression would reduce the size of this 912444480 byte file by 0 percent. Chi square distribution for 912444480 samples is 254.09, and randomly would exceed this value 50.43 percent of the times. Arithmetic mean value of data bytes is 127.4952 (127.5 = random). Monte Carlo value for Pi is 3.141641048 (error 0.00 percent). Serial correlation coefficient is 0.000022 (totally uncorrelated = 0.0). --- src/random.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/random.rs b/src/random.rs index 6b15182d..b433ac2a 100644 --- a/src/random.rs +++ b/src/random.rs @@ -109,4 +109,20 @@ mod tests { set.insert(rand); } } + + // I used this to perform statistical tests of the random generation function using ENT + // (https://fourmilab.ch/random/). + //#[test] + //fn write_to_file() { + //use std::io::Write; + //let mut f = std::fs::OpenOptions::new() + //.write(true) + //.create(true) + //.append(true) + //.open("random_out") + //.unwrap(); + //for _ in 0..10_000_000 { + //f.write(&random_bytes().unwrap()).unwrap(); + //} + //} }