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() + ); + } +}