Merge #293: Hot wallet support
20f394a452e425f4bdad0195637612499575a878 random: add a commented-out test i used to run ENT (Antoine Poinsot) 157eea989322eed2ea9553d3640e884fb657541a lib: re-export the bip39 dependency (Antoine Poinsot) 50f13d3e2e2802ebbc6efceccb03eafe352b1e77 signer: allow to the set the network for extended keys encoding (Antoine Poinsot) f5e7632c73abef4f27f5617c877caf8574b3fe3b signer: expose a method for signing a PSBT (Antoine Poinsot) b88874107e3b45876a63506ab67afbc710cd2a27 signer: add a method to get the xpub at a given path (Antoine Poinsot) 59e55ae9f2c33f4865ff28282325a5cf156ca37e signer: implement mnemonics storage, and initialization from storage (Antoine Poinsot) d341b6dea9d7eb9663eb9a4ef55617340146dc1e signer: cache the master xpriv (Antoine Poinsot) 6e3b951e5452a3cd4a050bde32ee930f6613e601 signer: a new module with a BIP39-based hot signer (Antoine Poinsot) Pull request description: This introduces a new `signer` module. Its purpose is to provide clients of the Liana daemon (such as the GUI) with tools to sign transactions. For now, the only signer available is a hot signer based on BIP39. It allows to generate new mnemonics from OS-provided randomness (with added randomness from the CPU if available and mixed-in contextual data). Mnemonics are stored in plaintext in a `mnemonics` folder at the root of a provided data directory. Fixes #49. ACKs for top commit: edouardparis: ACK 20f394a452e425f4bdad0195637612499575a878 Tree-SHA512: 299476fdec69139cab9428e8466b4d320798ecd91a08251cac9d1fb086e43cffdb296eca4061a022a7182b5df2101977bb8300ead0d31f3f75988792145bba7f
This commit is contained in:
commit
200d3777e0
75
Cargo.lock
generated
75
Cargo.lock
generated
@ -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"
|
||||
|
||||
12
Cargo.toml
12
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"
|
||||
|
||||
@ -7,9 +7,12 @@ mod database;
|
||||
pub mod descriptors;
|
||||
#[cfg(feature = "jsonrpc_server")]
|
||||
mod jsonrpc;
|
||||
mod random;
|
||||
pub mod signer;
|
||||
#[cfg(test)]
|
||||
mod testutils;
|
||||
|
||||
pub use bip39;
|
||||
pub use miniscript;
|
||||
|
||||
pub use crate::bitcoin::d::{BitcoindError, WalletError};
|
||||
|
||||
128
src/random.rs
Normal file
128
src/random.rs
Normal file
@ -0,0 +1,128 @@
|
||||
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<Option<[u8; 32]>, 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<Option<[u8; 32]>, 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
//}
|
||||
//}
|
||||
}
|
||||
605
src/signer.rs
Normal file
605
src/signer.rs
Normal file
@ -0,0 +1,605 @@
|
||||
//! 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, fs,
|
||||
io::{self, Write},
|
||||
path,
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use miniscript::bitcoin::{
|
||||
self,
|
||||
hashes::Hash,
|
||||
secp256k1,
|
||||
util::{
|
||||
bip32::{self, Error as Bip32Error},
|
||||
ecdsa,
|
||||
psbt::Psbt,
|
||||
sighash,
|
||||
},
|
||||
};
|
||||
|
||||
/// An error related to using a signer.
|
||||
#[derive(Debug)]
|
||||
pub enum SignerError {
|
||||
Randomness(random::RandomnessError),
|
||||
Mnemonic(bip39::Error),
|
||||
Bip32(Bip32Error),
|
||||
MnemonicStorage(io::Error),
|
||||
InsanePsbt,
|
||||
IncompletePsbt,
|
||||
}
|
||||
|
||||
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),
|
||||
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."
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
pub struct HotSigner {
|
||||
mnemonic: bip39::Mnemonic,
|
||||
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<fs::File, std::io::Error> {
|
||||
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,
|
||||
mnemonic: bip39::Mnemonic,
|
||||
) -> Result<Self, SignerError> {
|
||||
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(network: bitcoin::Network) -> Result<Self, SignerError> {
|
||||
// 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)?;
|
||||
Self::from_mnemonic(network, mnemonic)
|
||||
}
|
||||
|
||||
pub fn from_str(network: bitcoin::Network, s: &str) -> Result<Self, SignerError> {
|
||||
let mnemonic = bip39::Mnemonic::from_str(s).map_err(SignerError::Mnemonic)?;
|
||||
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<Vec<Self>, 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
|
||||
}
|
||||
|
||||
/// Get the fingerprint of the master xpub for this signer.
|
||||
pub fn fingerprint(
|
||||
&self,
|
||||
secp: &secp256k1::Secp256k1<impl secp256k1::Signing>,
|
||||
) -> 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.
|
||||
pub fn store(
|
||||
&self,
|
||||
datadir_root: &path::Path,
|
||||
network: bitcoin::Network,
|
||||
secp: &secp256k1::Secp256k1<impl secp256k1::Signing>,
|
||||
) -> 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.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(())
|
||||
}
|
||||
|
||||
fn xpriv_at(
|
||||
&self,
|
||||
der_path: &bip32::DerivationPath,
|
||||
secp: &secp256k1::Secp256k1<impl secp256k1::Signing>,
|
||||
) -> 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<impl secp256k1::Signing>,
|
||||
) -> bip32::ExtendedPubKey {
|
||||
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<impl secp256k1::Signing>,
|
||||
) -> Result<Psbt, SignerError> {
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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() {
|
||||
// Entropy isn't completely broken.
|
||||
assert_ne!(
|
||||
HotSigner::generate(bitcoin::Network::Bitcoin)
|
||||
.unwrap()
|
||||
.words(),
|
||||
HotSigner::generate(bitcoin::Network::Bitcoin)
|
||||
.unwrap()
|
||||
.words()
|
||||
);
|
||||
|
||||
// Roundtrips.
|
||||
let signer = HotSigner::generate(bitcoin::Network::Bitcoin).unwrap();
|
||||
let mnemonics_str = signer.mnemonic_str();
|
||||
assert_eq!(
|
||||
HotSigner::from_str(bitcoin::Network::Bitcoin, &mnemonics_str)
|
||||
.unwrap()
|
||||
.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]
|
||||
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();
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user