From 17154d823de676c27d01eff6e763452751e8a513 Mon Sep 17 00:00:00 2001 From: Olorunshogo Moses BAMTEFA Date: Sat, 24 Oct 2026 11:39:18 +0100 Subject: [PATCH] fix(key): support all BIP39 word counts and reject invalid values in key generate --- CHANGELOG.md | 1 + src/handlers/key.rs | 39 +++++++++++++++++++++++++++++++-------- tests/integration/init.rs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ec03d1..949ea45c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ page. See [DEVELOPMENT_CYCLE.md](DEVELOPMENT_CYCLE.md) for more details. - Fixed `warn!` messages, including the mainnet notice, being hidden unless `RUST_LOG` was set; warnings are now shown by default - Added `hwi` feature with a hardware wallet `hwi` command to list connected devices, register a wallet policy, display a receive address, and sign PSBTs. Supports Ledger, Coldcard, BitBox02, Jade and Specter. - Replaced plain `wallets` with `wallets list`, and added `wallets delete ` for unused saved wallet configurations. +- Fixed `key generate` silently producing a 24-word mnemonic for any `--entropy` value other than 12; 15, 18 and 21 are now supported and invalid values are rejected ## [4.0.0] diff --git a/src/handlers/key.rs b/src/handlers/key.rs index e7bb6457..f864104a 100644 --- a/src/handlers/key.rs +++ b/src/handlers/key.rs @@ -9,7 +9,7 @@ use bdk_wallet::keys::{ DerivableKey, DescriptorKey, ExtendedKey, GeneratableKey, GeneratedKey, bip39::WordCount, }; use bdk_wallet::miniscript::{self, Segwitv0}; -use clap::Parser; +use clap::{Parser, ValueEnum}; impl KeySubCommand { pub fn execute(&self, ctx: &mut AppContext) -> Result<(), Error> { @@ -26,6 +26,34 @@ impl KeySubCommand { } } } +/// Mnemonic lengths defined by BIP39. A local mirror of `WordCount`, which +/// doesn't derive `Clone` and so can't be parsed by clap directly. +#[derive(Clone, Copy, ValueEnum, Debug, Eq, PartialEq)] +pub enum MnemonicWordCount { + #[value(name = "12")] + Words12, + #[value(name = "15")] + Words15, + #[value(name = "18")] + Words18, + #[value(name = "21")] + Words21, + #[value(name = "24")] + Words24, +} + +impl From for WordCount { + fn from(word_count: MnemonicWordCount) -> Self { + match word_count { + MnemonicWordCount::Words12 => WordCount::Words12, + MnemonicWordCount::Words15 => WordCount::Words15, + MnemonicWordCount::Words18 => WordCount::Words18, + MnemonicWordCount::Words21 => WordCount::Words21, + MnemonicWordCount::Words24 => WordCount::Words24, + } + } +} + #[derive(Parser, Debug, Clone, PartialEq, Eq)] pub struct GenerateKeyCommand { /// Entropy level based on number of random seed mnemonic words. @@ -35,7 +63,7 @@ pub struct GenerateKeyCommand { long = "entropy", default_value = "12" )] - word_count: usize, + word_count: MnemonicWordCount, /// Seed password. #[arg(env = "PASSWORD", short = 'p', long = "password")] password: Option, @@ -46,13 +74,8 @@ impl AppCommand> for GenerateKeyCommand { fn execute(&self, ctx: &mut AppContext) -> Result { let secp = Secp256k1::new(); - let mnemonic_type = match self.word_count { - 12 => WordCount::Words12, - _ => WordCount::Words24, - }; - let mnemonic: GeneratedKey<_, miniscript::BareCtx> = - Mnemonic::generate((mnemonic_type, Language::English)) + 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()?; diff --git a/tests/integration/init.rs b/tests/integration/init.rs index c5907e13..ff92b747 100644 --- a/tests/integration/init.rs +++ b/tests/integration/init.rs @@ -19,6 +19,37 @@ mod test_key { .stdout(predicate::str::contains("\"fingerprint\":")); } + #[test] + fn test_cli_key_generate_word_counts() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + for word_count in [12, 15, 18, 21, 24] { + let output = cli + .key_cmd(&["generate", "--entropy", &word_count.to_string()]) + .output() + .expect("Failed to execute generate command"); + assert!(output.status.success(), "Generate command failed"); + + let json: Value = serde_json::from_slice(&output.stdout).expect("Invalid JSON"); + let mnemonic = json["mnemonic"].as_str().expect("Missing mnemonic"); + assert_eq!(mnemonic.split_whitespace().count(), word_count); + } + } + + #[test] + fn test_cli_key_generate_rejects_invalid_word_count() { + let temp_dir = TempDir::new().unwrap(); + let cli = BdkCli::new("testnet", Some(temp_dir.path().to_path_buf())); + + for word_count in ["0", "13", "999"] { + cli.key_cmd(&["generate", "--entropy", word_count]) + .assert() + .failure() + .stderr(predicate::str::contains("possible values")); + } + } + #[test] fn test_cli_key_derive() { let temp_dir = TempDir::new().unwrap();