From 3ecf3b872e967361ecf0391fbb92641c336d5f80 Mon Sep 17 00:00:00 2001 From: Ikeh Celestine Chukwudi Date: Wed, 11 Nov 2026 15:08:24 +0100 Subject: [PATCH 1/2] refactor(key): share derivation between generate and restore GenerateKeyCommand and RestoreKeyCommand repeated the same steps to derive the master key from a mnemonic, and the duplication is how #372 happened. Move them into key_from_mnemonic, called by both, and use the Display impl of Mnemonic instead of the manual fold, which gives the same output. Fixes #382 --- src/handlers/key.rs | 58 +++++++++++++++++++-------------------------- 1 file changed, 25 insertions(+), 33 deletions(-) diff --git a/src/handlers/key.rs b/src/handlers/key.rs index 648177e4..517f1ce8 100644 --- a/src/handlers/key.rs +++ b/src/handlers/key.rs @@ -3,6 +3,7 @@ use crate::error::BDKCliError as Error; use crate::handlers::{AppCommand, AppContext, Init}; use crate::utils::{output::FormatOutput, types::KeyResult}; use bdk_wallet::bip39::{Language, Mnemonic}; +use bdk_wallet::bitcoin::Network; use bdk_wallet::bitcoin::bip32::{DerivationPath, KeySource, Xpriv}; use bdk_wallet::bitcoin::key::Secp256k1; use bdk_wallet::keys::{ @@ -66,28 +67,10 @@ impl AppCommand> for GenerateKeyCommand { type Output = KeyResult; fn execute(&self, ctx: &mut AppContext) -> Result { - let secp = Secp256k1::new(); let mnemonic: GeneratedKey<_, miniscript::BareCtx> = Mnemonic::generate((self.word_count.into(), Language::English)) .map_err(|_| Error::Generic("Mnemonic generation error".to_string()))?; - let mnemonic = mnemonic.into_key(); - let xkey: ExtendedKey = (mnemonic.clone(), self.password.clone()).into_extended_key()?; - let xprv = xkey.into_xprv(ctx.network.into()).ok_or_else(|| { - Error::Generic("Privatekey info not found (should not happen)".to_string()) - })?; - let fingerprint = xprv.fingerprint(&secp); - let phrase = mnemonic - .words() - .fold("".to_string(), |phrase, w| phrase + w + " ") - .trim() - .to_string(); - - Ok(KeyResult { - xprv: xprv.to_string(), - mnemonic: Some(phrase), - fingerprint: Some(fingerprint.to_string()), - xpub: None, - }) + key_from_mnemonic(mnemonic.into_key(), self.password.clone(), ctx.network) } } @@ -151,20 +134,29 @@ impl AppCommand> for RestoreKeyCommand { type Output = KeyResult; fn execute(&self, ctx: &mut AppContext) -> Result { - let secp = Secp256k1::new(); - let mnemonic = Mnemonic::parse_in(Language::English, &self.mnemonic)?; - let xkey: ExtendedKey = (mnemonic.clone(), self.password.clone()).into_extended_key()?; - let xprv = xkey.into_xprv(ctx.network.into()).ok_or_else(|| { - Error::Generic("Privatekey info not found (should not happen)".to_string()) - })?; - let fingerprint = xprv.fingerprint(&secp); - - Ok(KeyResult { - xprv: xprv.to_string(), - mnemonic: Some(mnemonic.to_string()), - fingerprint: Some(fingerprint.to_string()), - xpub: None, - }) + key_from_mnemonic(mnemonic, self.password.clone(), ctx.network) } } + +/// Derives the master key from a mnemonic and optional passphrase, as used by +/// both `key generate` and `key restore`. +fn key_from_mnemonic( + mnemonic: Mnemonic, + password: Option, + network: Network, +) -> Result { + let secp = Secp256k1::new(); + let xkey: ExtendedKey = (mnemonic.clone(), password).into_extended_key()?; + let xprv = xkey.into_xprv(network.into()).ok_or_else(|| { + Error::Generic("Privatekey info not found (should not happen)".to_string()) + })?; + let fingerprint = xprv.fingerprint(&secp); + + Ok(KeyResult { + xprv: xprv.to_string(), + mnemonic: Some(mnemonic.to_string()), + fingerprint: Some(fingerprint.to_string()), + xpub: None, + }) +} From 2dfb344c726b038820823e113f1666f2d31060a9 Mon Sep 17 00:00:00 2001 From: Ikeh Celestine Chukwudi Date: Wed, 11 Nov 2026 16:08:17 +0100 Subject: [PATCH 2/2] refactor(key)!: rename --entropy and --password flags Rename key generate --entropy to --word_count (-e to -w), since the value is a number of words, and --password to --passphrase (env PASSWORD to PASSPHRASE) in key generate and key restore, the BIP-39 term. Expand the passphrase doc comment shown by --help, and update the tests and changelog. Fixes #381 --- CHANGELOG.md | 1 + src/handlers/key.rs | 28 +++++++++++++++------------- tests/integration/init.rs | 26 +++++++++++++------------- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45cf4fcd..256a5d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ page. See [DEVELOPMENT_CYCLE.md](DEVELOPMENT_CYCLE.md) for more details. - Fixed `create_tx`, `create_dns_tx`, `create_sp_tx` and `bump_fee` building transactions over Bitcoin Core's 400,000 wu (100,000 vB) standard weight limit, which no default node relays - Fixed `key restore` ignoring `--password`, which restored passphrase-protected mnemonics to the wrong key - Fixed `key generate` silently producing a 24-word mnemonic for any `--entropy` value other than 12; values other than 12 and 24 are now rejected +- BREAKING: renamed `key generate --entropy` to `--word_count` (`-e` to `-w`), and `--password` to `--passphrase` (env `PASSWORD` to `PASSPHRASE`) in `key generate` and `key restore` ## [4.0.0] diff --git a/src/handlers/key.rs b/src/handlers/key.rs index 517f1ce8..df87b83a 100644 --- a/src/handlers/key.rs +++ b/src/handlers/key.rs @@ -49,18 +49,19 @@ impl From for WordCount { #[derive(Parser, Debug, Clone, PartialEq, Eq)] pub struct GenerateKeyCommand { - /// Entropy level based on number of random seed mnemonic words. + /// Number of words in the generated mnemonic. #[arg( env = "WORD_COUNT", - short = 'e', - long = "entropy", + short = 'w', + long = "word_count", value_enum, default_value_t = MnemonicWordCount::Words12 )] word_count: MnemonicWordCount, - /// Seed password. - #[arg(env = "PASSWORD", short = 'p', long = "password")] - password: Option, + /// Optional BIP-39 passphrase. Restoring the key needs the same mnemonic and + /// passphrase; a different passphrase gives a different key, not an error. + #[arg(env = "PASSPHRASE", short = 'p', long = "passphrase")] + passphrase: Option, } impl AppCommand> for GenerateKeyCommand { @@ -70,7 +71,7 @@ impl AppCommand> for GenerateKeyCommand { let mnemonic: GeneratedKey<_, miniscript::BareCtx> = Mnemonic::generate((self.word_count.into(), Language::English)) .map_err(|_| Error::Generic("Mnemonic generation error".to_string()))?; - key_from_mnemonic(mnemonic.into_key(), self.password.clone(), ctx.network) + key_from_mnemonic(mnemonic.into_key(), self.passphrase.clone(), ctx.network) } } @@ -125,9 +126,10 @@ pub struct RestoreKeyCommand { /// Seed mnemonic words, must be quoted (eg. "word1 word2 ..."). #[arg(env = "MNEMONIC", short = 'm', long = "mnemonic")] mnemonic: String, - /// Seed password. - #[arg(env = "PASSWORD", short = 'p', long = "password")] - password: Option, + /// Optional BIP-39 passphrase. Restoring the key needs the same mnemonic and + /// passphrase; a different passphrase gives a different key, not an error. + #[arg(env = "PASSPHRASE", short = 'p', long = "passphrase")] + passphrase: Option, } impl AppCommand> for RestoreKeyCommand { @@ -135,7 +137,7 @@ impl AppCommand> for RestoreKeyCommand { fn execute(&self, ctx: &mut AppContext) -> Result { let mnemonic = Mnemonic::parse_in(Language::English, &self.mnemonic)?; - key_from_mnemonic(mnemonic, self.password.clone(), ctx.network) + key_from_mnemonic(mnemonic, self.passphrase.clone(), ctx.network) } } @@ -143,11 +145,11 @@ impl AppCommand> for RestoreKeyCommand { /// both `key generate` and `key restore`. fn key_from_mnemonic( mnemonic: Mnemonic, - password: Option, + passphrase: Option, network: Network, ) -> Result { let secp = Secp256k1::new(); - let xkey: ExtendedKey = (mnemonic.clone(), password).into_extended_key()?; + let xkey: ExtendedKey = (mnemonic.clone(), passphrase).into_extended_key()?; let xprv = xkey.into_xprv(network.into()).ok_or_else(|| { Error::Generic("Privatekey info not found (should not happen)".to_string()) })?; diff --git a/tests/integration/init.rs b/tests/integration/init.rs index e1725e4f..0ab8b36c 100644 --- a/tests/integration/init.rs +++ b/tests/integration/init.rs @@ -26,7 +26,7 @@ mod test_key { for word_count in [12, 24] { let output = cli - .key_cmd(&["generate", "--entropy", &word_count.to_string()]) + .key_cmd(&["generate", "--word_count", &word_count.to_string()]) .output() .expect("Failed to execute generate command"); assert!(output.status.success(), "Generate command failed"); @@ -43,7 +43,7 @@ mod test_key { let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); for word_count in ["0", "13", "15", "18", "21", "999"] { - cli.key_cmd(&["generate", "--entropy", word_count]) + cli.key_cmd(&["generate", "--word_count", word_count]) .assert() .failure() .stderr(predicate::str::contains("possible values")); @@ -134,12 +134,12 @@ mod test_key { } #[test] - fn test_cli_key_restore_with_password() { + fn test_cli_key_restore_with_passphrase() { let temp_dir = TempDir::new().unwrap(); let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); let generate_cmd = cli - .key_cmd(&["generate", "--password", "foo"]) + .key_cmd(&["generate", "--passphrase", "foo"]) .output() .expect("Failed to execute generate command"); assert!(generate_cmd.status.success(), "Generate command failed"); @@ -147,9 +147,9 @@ mod test_key { serde_json::from_slice(&generate_cmd.stdout).expect("Failed to parse JSON"); let mnemonic = generate_json["mnemonic"].as_str().unwrap(); - // Restoring with the same password must give back the same key + // Restoring with the same passphrase must give back the same key let restore_cmd = cli - .key_cmd(&["restore", "--mnemonic", mnemonic, "--password", "foo"]) + .key_cmd(&["restore", "--mnemonic", mnemonic, "--passphrase", "foo"]) .output() .expect("Failed to execute restore command"); assert!(restore_cmd.status.success(), "Restore command failed"); @@ -158,16 +158,16 @@ mod test_key { assert_eq!(generate_json["xprv"], restore_json["xprv"]); assert_eq!(generate_json["fingerprint"], restore_json["fingerprint"]); - // Restoring without the password must give a different key - let no_password_cmd = cli + // Restoring without the passphrase must give a different key + let no_passphrase_cmd = cli .key_cmd(&["restore", "--mnemonic", mnemonic]) - .env_remove("PASSWORD") + .env_remove("PASSPHRASE") .output() .expect("Failed to execute restore command"); - assert!(no_password_cmd.status.success(), "Restore command failed"); - let no_password_json: Value = - serde_json::from_slice(&no_password_cmd.stdout).expect("Failed to parse JSON"); - assert_ne!(generate_json["xprv"], no_password_json["xprv"]); + assert!(no_passphrase_cmd.status.success(), "Restore command failed"); + let no_passphrase_json: Value = + serde_json::from_slice(&no_passphrase_cmd.stdout).expect("Failed to parse JSON"); + assert_ne!(generate_json["xprv"], no_passphrase_json["xprv"]); } }