#![allow(clippy::result_large_err)]
#[macro_use]
extern crate clap;
#[macro_use]
extern crate amplify;
extern crate bitcoin_hwi as hwi;
extern crate miniscript_crate as miniscript;
extern crate strict_encoding_crate as strict_encoding;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{fs, io};
use aes::cipher::generic_array::GenericArray;
use aes::{Aes256, Block, BlockDecrypt, BlockEncrypt, NewBlockCipher};
use amplify::hex::ToHex;
use amplify::IoError;
use bip39::Mnemonic;
use bitcoin::consensus::{self, Decodable, Encodable};
use bitcoin::hashes::{sha256, Hash};
use bitcoin::secp256k1::rand::RngCore;
use bitcoin::secp256k1::{self, rand, Secp256k1, Signing};
use bitcoin::util::bip32;
use bitcoin::util::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey};
use bitcoin::XpubIdentifier;
use bitcoin_hd::{DerivationAccount, DerivationStandard, SegmentIndexes};
use clap::Parser;
use colored::Colorize;
use hwi::HWIDevice;
use miniscript::Descriptor;
use miniscript_crate::ForEachKey;
use psbt::serialize::{Deserialize, Serialize};
use psbt::sign::{MemoryKeyProvider, MemorySigningAccount, SignAll, SignError};
use psbt::Psbt;
use slip132::{KeyApplication, ToSlip132};
use wallet::hd::standards::DerivationBlockchain;
use wallet::hd::{Bip43, HardenedIndex};
#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug, Display)]
#[repr(u8)]
pub enum Network {
#[display("bitcoin")]
Bitcoin,
#[display("testnet")]
Testnet3,
#[display("signet")]
Signet,
}
impl Network {
#[inline]
pub fn is_testnet(self) -> bool { self != Network::Bitcoin }
}
impl From<Network> for DerivationBlockchain {
#[inline]
fn from(network: Network) -> Self {
match network {
Network::Bitcoin => DerivationBlockchain::Bitcoin,
Network::Testnet3 | Network::Signet => DerivationBlockchain::Testnet,
}
}
}
impl From<Network> for bitcoin::Network {
fn from(network: Network) -> Self {
match network {
Network::Bitcoin => bitcoin::Network::Bitcoin,
Network::Testnet3 => bitcoin::Network::Testnet,
Network::Signet => bitcoin::Network::Signet,
}
}
}
#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug)]
#[repr(u16)]
pub enum SeedType {
Bit128 = 128,
Bit160 = 160,
Bit192 = 192,
Bit224 = 224,
Bit256 = 256,
}
impl SeedType {
#[inline]
pub fn bit_len(self) -> usize { self as usize }
#[inline]
pub fn byte_len(self) -> usize {
match self {
SeedType::Bit128 => 16,
SeedType::Bit160 => 160 / 8,
SeedType::Bit192 => 192 / 8,
SeedType::Bit224 => 224 / 8,
SeedType::Bit256 => 32,
}
}
#[inline]
pub fn word_len(self) -> usize {
match self {
SeedType::Bit128 => 12,
SeedType::Bit160 => 15,
SeedType::Bit192 => 18,
SeedType::Bit224 => 21,
SeedType::Bit256 => 24,
}
}
}
fn decode(source: impl AsRef<[u8]>, password: &str) -> Vec<u8> {
let key = sha256::Hash::hash(password.as_bytes());
let key = GenericArray::from_slice(key.as_inner());
let cipher = Aes256::new(key);
let mut source = source.as_ref().to_vec();
if source.len() % 16 != 0 {
panic!("data length for encoding must be proportional to 16")
}
for chunk in source.chunks_mut(16) {
let block = Block::from_mut_slice(chunk);
cipher.decrypt_block(block);
}
source
}
fn encode(source: impl AsRef<[u8]>, password: &str) -> Vec<u8> {
let key = sha256::Hash::hash(password.as_bytes());
let key = GenericArray::from_slice(key.as_inner());
let cipher = Aes256::new(key);
let mut source = source.as_ref().to_vec();
if source.len() % 16 != 0 {
panic!("data length for encoding must be proportional to 16")
}
for chunk in source.chunks_mut(16) {
let block = Block::from_mut_slice(chunk);
cipher.encrypt_block(block);
}
source
}
struct Seed(Box<[u8]>);
impl Seed {
pub fn with(seed_type: SeedType) -> Seed {
let mut entropy = vec![0u8; seed_type.byte_len()];
rand::thread_rng().fill_bytes(&mut entropy);
Seed(Box::from(entropy))
}
pub fn read<P>(file: P, password: &str) -> io::Result<Seed>
where
P: AsRef<Path>,
{
let data = fs::read(file)?;
Ok(Seed(Box::from(decode(data, password))))
}
pub fn write<P>(&self, file: P, password: &str) -> io::Result<()>
where
P: AsRef<Path>,
{
fs::write(file, encode(&self.0, password))
}
#[inline]
pub fn as_entropy(&self) -> &[u8] { &self.0 }
#[inline]
pub fn master_xpriv(&self, testnet: bool) -> Result<ExtendedPrivKey, bip32::Error> {
ExtendedPrivKey::new_master(
if testnet {
bitcoin::Network::Testnet
} else {
bitcoin::Network::Bitcoin
},
self.as_entropy(),
)
}
}
trait SecretIo {
fn read<C>(
secp: &Secp256k1<C>,
reader: impl io::Read,
password: Option<&str>,
) -> Result<Self, consensus::encode::Error>
where
C: Signing,
Self: Sized;
fn write(
&self,
writer: impl io::Write,
password: Option<&str>,
) -> Result<(), consensus::encode::Error>;
}
impl SecretIo for MemorySigningAccount {
fn read<C>(
secp: &Secp256k1<C>,
mut reader: impl io::Read,
password: Option<&str>,
) -> Result<Self, consensus::encode::Error>
where
C: Signing,
{
let mut slice = [0u8; 20];
reader.read_exact(&mut slice)?;
let master_id = XpubIdentifier::from_inner(slice);
let len = u64::consensus_decode(&mut reader)?;
let mut path = Vec::with_capacity(len as usize);
for _ in 0..len {
path.push(ChildNumber::from(u32::consensus_decode(&mut reader)?));
}
let mut slice = [0u8; 80];
reader.read_exact(&mut slice)?;
if let Some(password) = password {
let data = decode(slice, password);
slice.copy_from_slice(&data);
}
let account_xpriv = ExtendedPrivKey::decode(&slice[..78]).map_err(|_| {
consensus::encode::Error::ParseFailed("account extended private key failure")
})?;
Ok(MemorySigningAccount::with(
secp,
master_id,
path,
account_xpriv,
))
}
fn write(
&self,
mut writer: impl io::Write,
password: Option<&str>,
) -> Result<(), consensus::encode::Error> {
writer.write_all(self.master_id())?;
let len = self.derivation().len() as u64;
len.consensus_encode(&mut writer)?;
for child in self.derivation() {
let index = u32::from(*child);
index.consensus_encode(&mut writer)?;
}
let mut data = self.account_xpriv().encode().to_vec();
data.resize(80, 0);
rand::thread_rng().fill_bytes(&mut data[78..]);
if let Some(password) = password {
data = encode(data, password);
}
writer.write_all(&data)?;
Ok(())
}
}
#[derive(Parser)]
#[derive(Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug)]
#[clap(
author,
version,
name = "btc-hot",
about = "Command-line file-based bitcoin hot wallet"
)]
pub struct Args {
#[clap(short = 'P', long, global = true)]
pub print_private: bool,
#[clap(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
#[derive(Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug)]
pub enum Command {
Seed {
output_file: PathBuf,
},
DeviceKeys {
#[clap(short, long, default_value = "0h")]
account: HardenedIndex,
#[clap(long, group = "network", required_unless_present_any = &["testnet"])]
mainnet: bool,
#[clap(long, group = "network")]
testnet: bool,
#[clap(long_help = "Possible values are:
- bip44: used for P2PKH (not recommended)
- bip84: used for P2WPKH
- bip49: used for P2WPKH-in-P2SH
- bip86: used for P2TR (Taproot!)
- bip45: used for legacy multisigs (P2SH, not recommended)
- bip48//1h: used for P2WSH-in-P2SH multisigs (deterministic order)
- bip48//2h: used for P2WSH multisigs (deterministic order)
- bip87: used for modern multisigs with descriptors (pre-MuSig)
- bip43/<purpose>h: non-standard purpose field")]
scheme: Option<Bip43>,
},
Derive {
seed_file: PathBuf,
#[clap(
short,
long,
long_help = "Possible values are:
- bip44: used for P2PKH (not recommended)
- bip84: used for P2WPKH
- bip49: used for P2WPKH-in-P2SH
- bip86: used for P2TR (Taproot!)
- bip45: used for legacy multisigs (P2SH, not recommended)
- bip48//1h: used for P2WSH-in-P2SH multisigs (deterministic order)
- bip48//2h: used for P2WSH multisigs (deterministic order)
- bip87: used for modern multisigs with descriptors (pre-MuSig)
- bip43/<purpose>h: non-standard purpose field",
default_value = "bip86"
)]
scheme: Bip43,
#[clap(short, long, default_value = "0'")]
account: HardenedIndex,
#[clap(long, group = "network", required_unless_present_any = &["testnet", "signet"])]
mainnet: bool,
#[clap(long, group = "network")]
testnet: bool,
#[clap(long, group = "network")]
signet: bool,
output_file: PathBuf,
},
Key {
#[clap(long)]
debug: bool,
seed_file: PathBuf,
derivation: DerivationPath,
},
Info {
file: PathBuf,
},
Sign {
#[clap(short, long)]
musig: bool,
psbt_file: PathBuf,
signing_account: PathBuf,
},
}
impl Args {
pub fn exec(self) -> Result<(), Error> {
match &self.command {
Command::Seed { output_file } => self.seed(output_file),
Command::DeviceKeys {
account,
mainnet: _,
testnet,
scheme,
} => self.devices(*account, scheme.as_ref(), *testnet),
Command::Derive {
seed_file,
scheme,
account,
mainnet,
testnet,
signet,
output_file,
} => {
let network = match (mainnet, testnet, signet) {
(true, false, false) => Network::Bitcoin,
(false, true, false) => Network::Testnet3,
(false, false, true) => Network::Signet,
_ => unreachable!("Clap unable to parse mutually exclusive network flags"),
};
self.derive(seed_file, scheme, *account, network, output_file)
}
Command::Info { file } => self.info(file),
Command::Sign {
musig,
psbt_file,
signing_account,
} => self.sign(psbt_file, signing_account, *musig),
Command::Key {
debug,
seed_file,
derivation,
} => self.key(seed_file, derivation, *debug),
}
}
fn seed(&self, output_file: &Path) -> Result<(), Error> {
let seed = Seed::with(SeedType::Bit128);
let password = rpassword::read_password_from_tty(Some("Password: "))?;
seed.write(output_file, &password)?;
let secp = Secp256k1::new();
self.info_seed(&secp, seed);
Ok(())
}
fn devices(
&self,
account: HardenedIndex,
scheme: Option<&Bip43>,
testnet: bool,
) -> Result<(), Error> {
let blockchain = if testnet {
DerivationBlockchain::Testnet
} else {
DerivationBlockchain::Bitcoin
};
let derivation =
scheme.map(|scheme| scheme.to_account_derivation(account.into(), blockchain));
println!();
if let Some(derivation) = derivation {
println!(
"{} {}:",
"Devices and xpubs for".bright_white(),
format!("{:#}", derivation).bright_green()
);
} else {
println!("{}", "Devices and all xpubs:".bright_white());
}
for device in match HWIDevice::enumerate() {
Err(_) => {
eprintln!(
"{}\n",
"no devices detected or some of devices are locked".red()
);
return Ok(());
}
Ok(devices) => devices,
} {
println!(
"{} {} {}",
device.fingerprint.to_string().yellow(),
device.device_type,
device.model
);
if let Some(scheme) = scheme {
let derivation =
scheme.to_account_derivation(ChildNumber::from(account), blockchain);
let derivation_string = derivation.to_string();
let hwikey = match device.get_xpub(
&derivation_string.parse().expect(
"ancient bitcoin version with different derivation path implementation",
),
testnet,
) {
Ok(hwikey) => hwikey,
Err(_) => {
eprintln!(
"{}\n",
"device does not support the used derivation schema".red()
);
continue;
}
};
println!("- {}\n", hwikey.xpub.to_string().green());
continue;
}
let descr = match device.get_descriptors(Some(account.first_index()), testnet) {
Ok(descr) => descr,
Err(err) => {
eprintln!(
"{}\n{}\n",
"device failure, probably it got locked, disconnected or used by another \
app"
.red(),
&format!("{:?}", err).dimmed()
);
continue;
}
};
for item in descr.receive {
let descr = match Descriptor::<DerivationAccount>::from_str(&item) {
Ok(descr) => descr,
Err(_) => {
eprintln!("{} {}\n", "unable to parse device descriptor".red(), item);
continue;
}
};
descr.for_any_key(|account| {
println!(
"{:#} - {}",
account.to_account_derivation_path(),
account.account_xpub.to_string().green()
);
false });
}
println!();
}
Ok(())
}
fn derive(
&self,
seed_file: &Path,
scheme: &Bip43,
account: HardenedIndex,
network: Network,
output_file: &Path,
) -> Result<(), Error> {
let secp = Secp256k1::new();
let seed_password = rpassword::read_password_from_tty(Some("Seed password: "))?;
let account_password = rpassword::read_password_from_tty(Some("Account password: "))?;
let account_password = if account_password.is_empty() {
None
} else {
Some(account_password)
};
let seed = Seed::read(seed_file, &seed_password)?;
let master_xpriv = seed.master_xpriv(network.is_testnet())?;
let master_xpub = ExtendedPubKey::from_priv(&secp, &master_xpriv);
let derivation = scheme.to_account_derivation(account.into(), network.into());
let account_xpriv = master_xpriv.derive_priv(&secp, &derivation)?;
let account =
MemorySigningAccount::with(&secp, master_xpub.identifier(), derivation, account_xpriv);
let file = fs::File::create(output_file)?;
account.write(file, account_password.as_deref())?;
self.info_account(account);
Ok(())
}
fn key(&self, seed_file: &Path, derivation: &DerivationPath, debug: bool) -> Result<(), Error> {
let secp = Secp256k1::new();
let seed_password = rpassword::read_password_from_tty(Some("Seed password: "))?;
let seed = Seed::read(seed_file, &seed_password)?;
let master_xpriv = seed.master_xpriv(false)?;
let master_xpub = ExtendedPubKey::from_priv(&secp, &master_xpriv);
let account = MemorySigningAccount::with(
&secp,
master_xpub.identifier(),
DerivationPath::master(),
master_xpriv,
);
let seckey = account.derive_seckey(&secp, derivation);
let keypair = account.derive_keypair(&secp, derivation);
let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &seckey);
let (xonly, _) = secp256k1::XOnlyPublicKey::from_keypair(&keypair);
println!("{}", "Derivation:".bright_white());
println!(
"{:-18} {}",
" - master xpubid:".bright_white(),
master_xpub.identifier()
);
println!(
"{:-18} {}{}",
" - derivation:".bright_white(),
format!("m=[{}]/", master_xpub.fingerprint()).bright_yellow(),
format!("{}", derivation)
.trim_start_matches("m/")
.bright_yellow()
);
println!();
println!("{}", "Public keys:".bright_white());
println!(
"{:-18} {}",
" - compressed:".bright_white(),
pubkey.to_string().bright_green()
);
println!(
"{:-18} {}",
" - uncompressed:".bright_white(),
pubkey.serialize_uncompressed().to_hex()
);
println!("{:-18} {}", " - x-coord only:".bright_white(), xonly);
if debug {
println!("{:-18} {:?}", " - platform repr:", pubkey);
}
if self.print_private {
println!();
println!(
"{:-18} {}",
"Private key:".bright_red(),
seckey.display_secret().to_string().black().dimmed()
);
}
println!();
Ok(())
}
fn info_seed<C>(&self, secp: &Secp256k1<C>, seed: Seed)
where
C: Signing,
{
if self.print_private {
let mnemonic = Mnemonic::from_entropy(seed.as_entropy()).expect("invalid seed");
println!(
"\n{:-18} {}",
"Mnemonic:".bright_white(),
mnemonic.to_string().black().dimmed()
);
}
let mut xpriv = seed.master_xpriv(false).expect("invalid seed");
let mut xpub = ExtendedPubKey::from_priv(secp, &xpriv);
println!("{}", "Master key:".bright_white());
println!(
"{:-18} {}",
" - fingerprint:".bright_white(),
xpub.fingerprint().to_string().bright_green()
);
println!("{:-18} {}", " - id:".bright_white(), xpub.identifier());
if self.print_private {
println!(
"{:-18} {}",
" - xprv mainnet:".bright_white(),
xpriv.to_string().black().dimmed()
);
xpriv.network = bitcoin::Network::Testnet;
println!(
"{:-18} {}",
" - xprv testnet:".bright_white(),
xpriv.to_string().black().dimmed()
);
}
println!(
"{:-18} {}",
" - xpub mainnet:".bright_white(),
xpub.to_string().bright_green()
);
xpub.network = bitcoin::Network::Testnet;
println!(
"{:-18} {}\n",
" - xpub testnet:".bright_white(),
xpub.to_string().bright_yellow()
);
}
fn info_account(&self, account: MemorySigningAccount) {
let key_application = KeyApplication::from_derivation_path(account.derivation().clone());
println!("\n{}", "Account:".bright_white());
println!(
"{:-18} {}",
" - fingerprint:".bright_white(),
account.account_fingerprint().to_string().bright_green()
);
println!("{:-18} {}", " - id:".bright_white(), account.account_id());
println!(
"{:-18} m=[{}]/{}",
" - derivation:".bright_white(),
account.master_fingerprint(),
format!("{}", account.derivation()).trim_start_matches("m/")
);
if self.print_private {
let account_xpriv = account.account_xpriv();
println!(
"{:-18} {}",
" - xpriv:".bright_white(),
account_xpriv.to_string().black().dimmed()
);
if let Some(key_application) = key_application {
println!(
"{:-18} {}",
" - slip132 priv:".bright_white(),
account_xpriv
.to_slip132_string(key_application, account_xpriv.network)
.black()
.dimmed()
);
}
}
let account_xpub = account.account_xpub();
println!(
"{:-18} {}",
" - xpub:".bright_white(),
account_xpub.to_string().bright_green()
);
if let Some(key_application) = key_application {
println!(
"{:-18} {}",
" - slip132 pub:".bright_white(),
account_xpub
.to_slip132_string(key_application, account_xpub.network)
.bright_green()
);
}
if let Some(descriptor) = account.recommended_descriptor() {
println!(
"{:-18}\n{}\n",
"Recommended wallet descriptor:".bright_white(),
descriptor.to_string().bright_blue()
);
} else {
println!(
"{:-18}\n{}\n",
"Recommended use in wallet descriptor:".bright_white(),
account.to_account().to_string().bright_blue()
);
}
}
fn info(&self, path: &Path) -> Result<(), Error> {
let secp = Secp256k1::new();
let file = fs::File::open(path)?;
let password = rpassword::read_password_from_tty(Some("Password: "))?;
{
let password = if password.is_empty() {
None
} else {
Some(password.clone())
};
if let Ok(account) = MemorySigningAccount::read(&secp, file, password.as_deref()) {
self.info_account(account);
return Ok(());
}
}
if let Ok(seed) = Seed::read(path, &password) {
self.info_seed(&secp, seed);
return Ok(());
}
eprintln!(
"{} can't detect file format for `{}`",
"Error:".bright_red(),
path.display()
);
Ok(())
}
fn sign(&self, psbt_path: &Path, account_path: &Path, musig: bool) -> Result<(), Error> {
let password = rpassword::read_password_from_tty(Some("Account password: "))?;
let password = if password.is_empty() {
None
} else {
Some(password)
};
let secp = Secp256k1::new();
let file = fs::File::open(account_path)?;
let account = MemorySigningAccount::read(&secp, file, password.as_deref())?;
println!("Signing with {}\n", account.to_account());
let data = fs::read(psbt_path)?;
let mut psbt = Psbt::deserialize(&data)?;
let mut key_provider = MemoryKeyProvider::with(&secp, musig);
key_provider.add_account(account);
let sig_count = psbt.sign_all(&key_provider)?;
println!("Done {} signatures\n", sig_count.to_string().bright_green());
fs::write(psbt_path, psbt.serialize())?;
Ok(())
}
}
#[derive(Debug, Display, Error, From)]
#[display(inner)]
pub enum Error {
#[from(io::Error)]
Io(IoError),
#[from]
Bip39(bip39::Error),
#[from]
Bip32(bip32::Error),
#[from]
Encoding(consensus::encode::Error),
#[from]
StrictEncoding(strict_encoding::Error),
#[from]
Signing(SignError),
#[from]
#[display(Debug)]
Hwi(hwi::error::Error),
}
fn main() {
let args = Args::parse();
if let Err(err) = args.exec() {
eprintln!("{}: {}\n", "Error".bright_red(), err);
}
}