use std::io::{self, Write};
use std::process;
use btc_keygen::PrivateKey;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "btc-keygen")]
#[command(about = "Minimal offline Bitcoin key generator for cold storage")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Generate {
#[arg(long)]
from_hex: Option<String>,
#[arg(long)]
hex: bool,
#[arg(long)]
pubkey: bool,
#[arg(long)]
json: bool,
},
}
struct KeypairOutput {
address: String,
wif: String,
private_key_hex: Option<String>,
pubkey_hex: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Format {
Plain,
Json,
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Generate {
from_hex,
hex,
pubkey,
json,
} => {
run_generate(from_hex, hex, pubkey, json);
}
}
}
fn run_generate(from_hex: Option<String>, include_hex: bool, include_pubkey: bool, json: bool) {
let mut stderr = io::stderr().lock();
if let Err(e) = print_warnings(&mut stderr) {
eprintln!("failed to write warnings: {}", e);
process::exit(1);
}
drop(stderr);
let private_key = if let Some(hex) = from_hex {
match PrivateKey::from_hex(&hex) {
Ok(key) => key,
Err(e) => {
eprintln!("invalid private key: {}", e);
process::exit(1);
}
}
} else {
match btc_keygen::generate() {
Ok(key) => key,
Err(e) => {
eprintln!("key generation failed: {}", e);
process::exit(1);
}
}
};
let wif_str = btc_keygen::encode_wif(&private_key);
let compressed_pubkey = btc_keygen::derive_pubkey(&private_key);
let address = btc_keygen::derive_address(&compressed_pubkey);
let private_key_hex = if include_hex {
Some(
private_key
.as_bytes()
.iter()
.map(|b| format!("{:02x}", b))
.collect(),
)
} else {
None
};
let pubkey_hex = if include_pubkey {
Some(
compressed_pubkey
.iter()
.map(|b| format!("{:02x}", b))
.collect(),
)
} else {
None
};
let keypair = KeypairOutput {
address,
wif: wif_str,
private_key_hex,
pubkey_hex,
};
let format = if json { Format::Json } else { Format::Plain };
if let Err(e) = print_output(&keypair, format) {
eprintln!("failed to write output: {}", e);
process::exit(1);
}
}
fn print_output(keypair: &KeypairOutput, format: Format) -> io::Result<()> {
let stdout = io::stdout();
let mut handle = stdout.lock();
format_output(&mut handle, keypair, format)
}
fn print_warnings(stderr: &mut dyn Write) -> io::Result<()> {
writeln!(stderr, "=== btc-keygen: one-time key generation ===")?;
writeln!(stderr)?;
writeln!(
stderr,
"The address and private key printed below belong together."
)?;
writeln!(
stderr,
"The private key is required to spend funds sent to this address."
)?;
writeln!(stderr)?;
writeln!(stderr, "This tool does not save or store any secrets.")?;
writeln!(
stderr,
"If you lose the private key output, funds sent to this address"
)?;
writeln!(stderr, "may be permanently inaccessible.")?;
writeln!(stderr)?;
writeln!(
stderr,
"Re-running this tool generates a new, different keypair."
)?;
writeln!(stderr, "It does NOT recover a previously generated key.")?;
writeln!(stderr, "================================================")?;
Ok(())
}
fn format_output(
writer: &mut dyn Write,
keypair: &KeypairOutput,
format: Format,
) -> io::Result<()> {
match format {
Format::Plain => format_plain(writer, keypair),
Format::Json => format_json(writer, keypair),
}
}
fn format_plain(writer: &mut dyn Write, keypair: &KeypairOutput) -> io::Result<()> {
writeln!(writer, "address: {}", keypair.address)?;
writeln!(writer, "wif: {}", keypair.wif)?;
if let Some(ref hex) = keypair.private_key_hex {
writeln!(writer, "private_key_hex: {}", hex)?;
}
if let Some(ref pk) = keypair.pubkey_hex {
writeln!(writer, "pubkey_hex: {}", pk)?;
}
Ok(())
}
fn format_json(writer: &mut dyn Write, keypair: &KeypairOutput) -> io::Result<()> {
write!(writer, "{{")?;
write!(writer, "\"address\":\"{}\"", keypair.address)?;
write!(writer, ",\"wif\":\"{}\"", keypair.wif)?;
if let Some(ref hex) = keypair.private_key_hex {
write!(writer, ",\"private_key_hex\":\"{}\"", hex)?;
}
if let Some(ref pk) = keypair.pubkey_hex {
write!(writer, ",\"pubkey_hex\":\"{}\"", pk)?;
}
writeln!(writer, "}}")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_keypair() -> KeypairOutput {
KeypairOutput {
address: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".into(),
wif: "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn".into(),
private_key_hex: None,
pubkey_hex: None,
}
}
fn sample_keypair_all_fields() -> KeypairOutput {
KeypairOutput {
address: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".into(),
wif: "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn".into(),
private_key_hex: Some(
"0000000000000000000000000000000000000000000000000000000000000001".into(),
),
pubkey_hex: Some(
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".into(),
),
}
}
#[test]
fn test_plain_output_contains_address() {
let mut buf = Vec::new();
let kp = sample_keypair();
format_output(&mut buf, &kp, Format::Plain).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("bc1q"), "plain output must contain address");
}
#[test]
fn test_plain_output_contains_wif() {
let mut buf = Vec::new();
let kp = sample_keypair();
format_output(&mut buf, &kp, Format::Plain).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn"),
"plain output must contain WIF"
);
}
#[test]
fn test_plain_output_both_address_and_wif() {
let mut buf = Vec::new();
let kp = sample_keypair();
format_output(&mut buf, &kp, Format::Plain).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("bc1q"));
assert!(output.contains("KwDiBf89"));
}
#[test]
fn test_hex_included_when_present() {
let mut buf = Vec::new();
let kp = sample_keypair_all_fields();
format_output(&mut buf, &kp, Format::Plain).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("0000000000000000000000000000000000000000000000000000000000000001"),
"output must include hex when field is Some"
);
}
#[test]
fn test_pubkey_included_when_present() {
let mut buf = Vec::new();
let kp = sample_keypair_all_fields();
format_output(&mut buf, &kp, Format::Plain).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"),
"output must include pubkey hex when field is Some"
);
}
#[test]
fn test_json_output_is_valid_json() {
let mut buf = Vec::new();
let kp = sample_keypair();
format_output(&mut buf, &kp, Format::Json).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.starts_with('{'), "JSON must start with {{");
assert!(output.trim().ends_with('}'), "JSON must end with }}");
}
#[test]
fn test_json_contains_address_and_wif_fields() {
let mut buf = Vec::new();
let kp = sample_keypair();
format_output(&mut buf, &kp, Format::Json).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("\"address\""),
"JSON must have 'address' field"
);
assert!(output.contains("\"wif\""), "JSON must have 'wif' field");
}
#[test]
fn test_json_all_fields_when_present() {
let mut buf = Vec::new();
let kp = sample_keypair_all_fields();
format_output(&mut buf, &kp, Format::Json).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("\"address\""));
assert!(output.contains("\"wif\""));
assert!(output.contains("\"private_key_hex\""));
assert!(output.contains("\"pubkey_hex\""));
}
#[test]
fn test_json_omits_optional_fields_when_absent() {
let mut buf = Vec::new();
let kp = sample_keypair();
format_output(&mut buf, &kp, Format::Json).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
!output.contains("\"private_key_hex\""),
"JSON must omit private_key_hex when not requested"
);
assert!(
!output.contains("\"pubkey_hex\""),
"JSON must omit pubkey_hex when not requested"
);
}
#[test]
fn test_warnings_contain_key_safety_messages() {
let mut buf = Vec::new();
print_warnings(&mut buf).unwrap();
let warnings = String::from_utf8(buf).unwrap();
assert!(
warnings.to_lowercase().contains("not store")
|| warnings.to_lowercase().contains("does not save"),
"warnings must state that secrets are not stored"
);
assert!(
warnings.to_lowercase().contains("new keypair")
|| warnings.to_lowercase().contains("different"),
"warnings must state re-running creates a new keypair"
);
}
#[test]
fn test_plain_output_does_not_contain_warnings() {
let mut buf = Vec::new();
let kp = sample_keypair();
format_output(&mut buf, &kp, Format::Plain).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
!output.to_lowercase().contains("warning"),
"stdout (format_output) must not contain warning text"
);
}
}