#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use bitcoin::NetworkKind;
use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv, Xpub};
use bitcoin::secp256k1::Secp256k1;
use clap::Parser;
use std::io::{self, IsTerminal, Write};
use zeroize::{Zeroize, Zeroizing};
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Args {
#[arg(short, long, default_value_t = 84, value_parser = parse_hardened_index)]
purpose: u32,
#[arg(short, long, default_value_t = 0, value_parser = parse_hardened_index)]
account: u32,
#[arg(short = 's', long, default_value_t = false)]
secret: bool,
#[arg(short, long, default_value_t = false)]
testnet: bool,
}
fn parse_seed_words(s: &str) -> Result<bip39::Mnemonic, String> {
bip39::Mnemonic::parse(s).map_err(|e| format!("invalid seed phrase: {e}"))
}
fn parse_hardened_index(s: &str) -> Result<u32, String> {
match s.parse::<u32>() {
Ok(idx) if ChildNumber::from_hardened_idx(idx).is_ok() => Ok(idx),
Ok(idx) => Err(format!(
"{idx} exceeds the maximum hardened child index (2^31 - 1)"
)),
Err(_) => Err(format!("invalid unsigned integer: {s}")),
}
}
fn read_seed_words() -> Result<bip39::Mnemonic, String> {
let mut input = Zeroizing::new(String::new());
if io::stdin().is_terminal() {
print!("Seed words: ");
io::stdout()
.flush()
.map_err(|e| format!("failed to flush stdout: {e}"))?;
}
io::stdin()
.read_line(&mut input)
.map_err(|e| format!("failed to read seed words: {e}"))?;
parse_seed_words(input.trim())
}
fn read_passphrase() -> Result<Zeroizing<String>, String> {
let mut passphrase = Zeroizing::new(String::new());
if io::stdin().is_terminal() {
prompt_passphrase(&mut io::stdin().lock(), &mut passphrase)?;
} else {
io::stdin()
.read_line(&mut passphrase)
.map_err(|e| format!("failed to read passphrase: {e}"))?;
trim_line_ending(&mut passphrase);
if passphrase.is_empty() {
let tty = std::fs::OpenOptions::new()
.read(true)
.open("/dev/tty")
.map_err(|e| {
format!("no passphrase on standard input and cannot prompt for one: {e}")
})?;
prompt_passphrase(&mut io::BufReader::new(tty), &mut passphrase)?;
}
}
if passphrase.is_empty() {
return Err("empty passphrase; omit the -s flag to derive without one".to_string());
}
Ok(passphrase)
}
fn prompt_passphrase(
reader: &mut impl io::BufRead,
passphrase: &mut Zeroizing<String>,
) -> Result<(), String> {
print!("BIP39 passphrase: ");
io::stdout()
.flush()
.map_err(|e| format!("failed to flush stdout: {e}"))?;
reader
.read_line(passphrase)
.map_err(|e| format!("failed to read passphrase: {e}"))?;
trim_line_ending(passphrase);
Ok(())
}
fn trim_line_ending(s: &mut String) {
while matches!(s.chars().last(), Some('\r' | '\n')) {
s.pop();
}
}
fn master_key_from_seed(
mnemonic: &bip39::Mnemonic,
passphrase: &str,
network: NetworkKind,
) -> Result<Xpriv, String> {
let seed = Zeroizing::new(mnemonic.to_seed(passphrase));
Xpriv::new_master(network, &seed[..]).map_err(|e| e.to_string())
}
fn main() -> Result<(), String> {
let args = Args::parse();
let mnemonic = read_seed_words()?;
let passphrase = if args.secret {
read_passphrase()?
} else {
Zeroizing::new(String::new())
};
let network = if args.testnet {
NetworkKind::Test
} else {
NetworkKind::Main
};
let mut master = master_key_from_seed(&mnemonic, &passphrase, network)?;
eprintln!(
"\nWARNING: The master key and secret account key will remain in your terminal scrollback."
);
eprintln!("Write them down, then clear your terminal (Cmd+K) when done.\n");
println!("\nmaster key: {}", master);
println!("fingerprint: {}", master.fingerprint(&Secp256k1::new()));
println!("Derived keys for:");
println!("purpose: {}", args.purpose);
println!("account: {}", args.account);
let keys = bip380_account_keys(&master, args.purpose, args.account);
master.private_key.non_secure_erase();
<bitcoin::bip32::ChainCode as AsMut<[u8; 32]>>::as_mut(&mut master.chain_code).zeroize();
let (seckey, pubkey) = keys?;
println!("\nSecret account key: {}", *seckey);
println!("Public account key: {}", pubkey);
Ok(())
}
fn bip380_account_keys(
master: &Xpriv,
purpose: u32,
account: u32,
) -> Result<(Zeroizing<String>, String), String> {
let secp = Secp256k1::new();
let coin_type = if master.network.is_mainnet() { 0 } else { 1 };
let path = DerivationPath::from(vec![
ChildNumber::from_hardened_idx(purpose)
.map_err(|e| format!("invalid purpose index {purpose}: {e}"))?,
ChildNumber::from_hardened_idx(coin_type)
.map_err(|e| format!("invalid coin type index {coin_type}: {e}"))?,
ChildNumber::from_hardened_idx(account)
.map_err(|e| format!("invalid account index {account}: {e}"))?,
]);
let mut account_xprv = master
.derive_priv(&secp, &path)
.map_err(|e| format!("BIP32 derivation failed: {e}"))?;
let account_xpub = Xpub::from_priv(&secp, &account_xprv);
let origin = format!(
"[{}/{}'/{}'/{}']",
master.fingerprint(&secp),
purpose,
coin_type,
account
);
let xprv_expr = Zeroizing::new(format!("{}{}/<0;1>/*", origin, account_xprv));
account_xprv.private_key.non_secure_erase();
<bitcoin::bip32::ChainCode as AsMut<[u8; 32]>>::as_mut(&mut account_xprv.chain_code).zeroize();
let xpub_expr = format!("{}{}/<0;1>/*", origin, account_xpub);
Ok((xprv_expr, xpub_expr))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bip84_mainnet_test() {
let mnemonic = bip39::Mnemonic::parse(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
)
.unwrap();
let seed = mnemonic.to_seed("");
let master = Xpriv::new_master(NetworkKind::Main, &seed).unwrap();
let (xprv_expr, xpub_expr) = bip380_account_keys(&master, 84, 0).unwrap();
assert_eq!(
&*xprv_expr,
"[73c5da0a/84'/0'/0']xprv9ybY78BftS5UGANki6oSifuQEjkpyAC8ZmBvBNTshQnCBcxnefjHS7buPMkkqhcRzmoGZ5bokx7GuyDAiktd5HemohAU4wV1ZPMDRmLpBMm/<0;1>/*"
);
assert_eq!(
xpub_expr,
"[73c5da0a/84'/0'/0']xpub6CatWdiZiodmUeTDp8LT5or8nmbKNcuyvz7WyksVFkKB4RHwCD3XyuvPEbvqAQY3rAPshWcMLoP2fMFMKHPJ4ZeZXYVUhLv1VMrjPC7PW6V/<0;1>/*"
);
}
#[test]
fn bip84_testnet_test() {
let mnemonic = bip39::Mnemonic::parse(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
)
.unwrap();
let seed = mnemonic.to_seed("");
let master = Xpriv::new_master(NetworkKind::Test, &seed).unwrap();
let (xprv_expr, xpub_expr) = bip380_account_keys(&master, 84, 0).unwrap();
assert!(xprv_expr.contains("/84'/1'/0']tprv"));
assert!(xpub_expr.contains("/84'/1'/0']tpub"));
}
#[test]
fn bip84_fingerprint_matches_master() {
let mnemonic = bip39::Mnemonic::parse(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
)
.unwrap();
let seed = mnemonic.to_seed("");
let master = Xpriv::new_master(NetworkKind::Main, &seed).unwrap();
let (xprv_expr, xpub_expr) = bip380_account_keys(&master, 84, 0).unwrap();
let secp = Secp256k1::new();
let expected_origin = format!("[{}/84'/0'/0']", master.fingerprint(&secp));
assert!(xprv_expr.starts_with(&expected_origin));
assert!(xpub_expr.starts_with(&expected_origin));
}
#[test]
fn hardened_index_accepts_values_below_2_to_the_31() {
assert_eq!(parse_hardened_index("0"), Ok(0));
assert_eq!(parse_hardened_index("84"), Ok(84));
assert_eq!(parse_hardened_index("2147483647"), Ok(2_147_483_647));
}
#[test]
fn hardened_index_rejects_out_of_range_and_malformed_input() {
assert!(parse_hardened_index("2147483648").is_err());
assert!(parse_hardened_index("4294967295").is_err());
assert!(parse_hardened_index("-1").is_err());
assert!(parse_hardened_index("abc").is_err());
}
#[test]
fn parse_seed_words_accepts_valid_12_and_24_word_phrases() {
assert!(
parse_seed_words(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
)
.is_ok()
);
assert!(
parse_seed_words(
"legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will"
)
.is_ok()
);
}
#[test]
fn parse_seed_words_rejects_bad_checksum_unknown_word_and_empty_input() {
assert!(
parse_seed_words(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
)
.is_err()
);
assert!(
parse_seed_words(
"notaword abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
)
.is_err()
);
assert!(parse_seed_words("").is_err());
}
#[test]
fn prompt_passphrase_reads_line_and_strips_line_ending() {
let mut reader = io::BufReader::new(&b"hunter2\r\n"[..]);
let mut passphrase = Zeroizing::new(String::new());
prompt_passphrase(&mut reader, &mut passphrase).unwrap();
assert_eq!(&*passphrase, "hunter2");
}
#[test]
fn prompt_passphrase_preserves_other_whitespace() {
let mut reader = io::BufReader::new(&b" two words \n"[..]);
let mut passphrase = Zeroizing::new(String::new());
prompt_passphrase(&mut reader, &mut passphrase).unwrap();
assert_eq!(&*passphrase, " two words ");
}
#[test]
fn trim_line_ending_strips_lf_and_crlf_only() {
for (input, expected) in [
("abc\n", "abc"),
("abc\r\n", "abc"),
("abc", "abc"),
("abc ", "abc "),
("", ""),
] {
let mut s = String::from(input);
trim_line_ending(&mut s);
assert_eq!(s, expected);
}
}
#[test]
fn master_key_known_answer_bip39_legal_winner() {
let mnemonic = parse_seed_words(
"legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will",
)
.unwrap();
let master = master_key_from_seed(&mnemonic, "TREZOR", NetworkKind::Main).unwrap();
assert_eq!(
master.to_string(),
"xprv9s21ZrQH143K3Lv9MZLj16np5GzLe7tDKQfVusBni7toqJGcnKRtHSxUwbKUyUWiwpK55g1DUSsw76TF1T93VT4gz4wt5RM23pkaQLnvBh7"
);
}
#[test]
fn master_key_known_answer_bip39_legal_winner_testnet() {
let mnemonic = parse_seed_words(
"legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will",
)
.unwrap();
let master = master_key_from_seed(&mnemonic, "TREZOR", NetworkKind::Test).unwrap();
assert_eq!(
master.to_string(),
"tprv8ZgxMBicQKsPeA9g28CEAkQoPQQYsdvDexacnHcFC6PHcu1hmgmdoCKvrmV8yqu3KFqr5mcydoTjZwzz8fUzJWLHWiABjn54xvVzr3oUVN7"
);
}
#[test]
fn master_key_testnet_shares_key_material_with_mainnet() {
let mnemonic = parse_seed_words(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
)
.unwrap();
let mainnet = master_key_from_seed(&mnemonic, "", NetworkKind::Main).unwrap();
let testnet = master_key_from_seed(&mnemonic, "", NetworkKind::Test).unwrap();
assert_eq!(mainnet.private_key, testnet.private_key);
assert_eq!(mainnet.chain_code, testnet.chain_code);
assert_ne!(mainnet.to_string(), testnet.to_string());
assert!(testnet.to_string().starts_with("tprv"));
}
#[test]
fn master_key_fingerprint_matches_bip84_vector() {
let mnemonic = parse_seed_words(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
)
.unwrap();
let master = master_key_from_seed(&mnemonic, "", NetworkKind::Main).unwrap();
assert_eq!(
master.fingerprint(&Secp256k1::new()).to_string(),
"73c5da0a"
);
}
#[test]
fn master_key_different_passphrases_different_keys() {
let mnemonic = parse_seed_words(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
)
.unwrap();
let a = master_key_from_seed(&mnemonic, "", NetworkKind::Main).unwrap();
let b = master_key_from_seed(&mnemonic, "secret", NetworkKind::Main).unwrap();
assert_ne!(a, b);
}
}