From 7786887af789b0dcf2b0676045742c99c5f6d6f6 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Fri, 29 May 2026 06:46:44 +0100 Subject: [PATCH] #171 BIP39 Mnemonic Support --- Cargo.lock | 72 ++++++++++++++++++ Cargo.toml | 2 + src/commands/wallet.rs | 166 +++++++++++++++++++++++++++++++++++++++-- src/utils/mnemonic.rs | 147 ++++++++++++++++++++++++++++++++++++ src/utils/mod.rs | 3 +- 5 files changed, 381 insertions(+), 9 deletions(-) create mode 100644 src/utils/mnemonic.rs diff --git a/Cargo.lock b/Cargo.lock index 46f87afe..c17dbd43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,6 +135,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -175,6 +181,19 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.6", + "rand_core 0.6.4", + "serde", + "unicode-normalization", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -190,6 +209,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bitcoin_hashes" +version = "0.14.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f" +dependencies = [ + "hex-conservative", +] + [[package]] name = "bitflags" version = "2.11.0" @@ -1050,6 +1078,15 @@ dependencies = [ "serde", ] +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + [[package]] name = "hidapi" version = "2.6.5" @@ -1063,6 +1100,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -2259,6 +2305,7 @@ dependencies = [ "anyhow", "argon2", "base64 0.21.7", + "bip39", "chrono", "clap", "clap_complete", @@ -2269,6 +2316,7 @@ dependencies = [ "dirs", "ed25519-dalek", "hidapi", + "hmac", "indicatif", "libloading", "mockito", @@ -2473,6 +2521,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.1" @@ -2634,6 +2697,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-width" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 96dfdd5e..2babebb9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,8 @@ sha2 = "0.10" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "fmt"] } tracing-appender = "0.2" +bip39 = { version = "2", features = ["rand", "std"] } +hmac = "0.12" [features] hardware-wallet = ["dep:hidapi"] diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index dfcca6c7..ec8fc811 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -1,4 +1,4 @@ -use crate::utils::{config, crypto, hardware_wallet, horizon, multisig, print as p}; +use crate::utils::{config, crypto, hardware_wallet, horizon, mnemonic, multisig, print as p}; use anyhow::{Context, Result}; use chrono::Utc; use clap::Subcommand; @@ -62,6 +62,15 @@ pub enum WalletCommands { /// (requires --encrypt) #[arg(long, default_value = "false", requires = "encrypt")] strict: bool, + /// Generate a BIP39 recovery phrase instead of a random key + #[arg(long, default_value = "false")] + mnemonic: bool, + /// Mnemonic length: 12 or 24 words (requires --mnemonic) + #[arg(long, default_value = "24", requires = "mnemonic", value_parser = ["12", "24"])] + words: String, + /// Account index for SEP-0005 path m/44'/148'/index' (requires --mnemonic) + #[arg(long, default_value = "0", requires = "mnemonic")] + account_index: u32, }, /// List all saved wallets List, @@ -107,11 +116,25 @@ pub enum WalletCommands { #[arg(long)] output: PathBuf, }, - /// Import wallets from a JSON backup file + /// Import a wallet from a JSON backup or BIP39 recovery phrase Import { + /// Wallet name (required with --mnemonic) + name: Option, /// Path to backup JSON file - #[arg(long)] - file: PathBuf, + #[arg(long, group = "source")] + file: Option, + /// Import from a BIP39 recovery phrase (prompted interactively) + #[arg(long, group = "source")] + mnemonic: bool, + /// Account index for SEP-0005 path m/44'/148'/index' + #[arg(long, default_value = "0")] + account_index: u32, + /// Network to associate with this wallet + #[arg(long, value_parser = ["testnet", "mainnet"])] + network: Option, + /// Encrypt the imported secret key with a passphrase at rest + #[arg(long, default_value = "false")] + encrypt: bool, }, /// Connect to a hardware wallet (Ledger/Trezor) and show device info @@ -215,7 +238,19 @@ pub fn handle(cmd: WalletCommands) -> Result<()> { network, encrypt, strict, - } => create(name, fund, network, encrypt, strict), + mnemonic: use_mnemonic, + words, + account_index, + } => create( + name, + fund, + network, + encrypt, + strict, + use_mnemonic, + words, + account_index, + ), WalletCommands::List => list(), WalletCommands::Show { name, reveal } => show(name, reveal), WalletCommands::Fund { name } => fund_wallet(name), @@ -228,7 +263,14 @@ pub fn handle(cmd: WalletCommands) -> Result<()> { encrypt, } => rotate_wallet(name, fund, network, encrypt), WalletCommands::Export { name, output } => export_wallet(name, output), - WalletCommands::Import { file } => import_wallets(file), + WalletCommands::Import { + name, + file, + mnemonic: from_mnemonic, + account_index, + network, + encrypt, + } => import_wallet(name, file, from_mnemonic, account_index, network, encrypt), WalletCommands::Connect { device } => connect_hardware(device), WalletCommands::HwAddress { device, path } => hw_address(device, &path), WalletCommands::HwStatus { device } => hw_status(device), @@ -351,12 +393,35 @@ fn generate_keypair() -> (String, String) { (public_key, secret_key) } +fn parse_word_count(words: &str) -> Result { + match words { + "12" => Ok(mnemonic::WordCount::Words12), + "24" => Ok(mnemonic::WordCount::Words24), + _ => anyhow::bail!("--words must be 12 or 24"), + } +} + +fn prompt_recovery_phrase() -> Result { + use dialoguer::Input; + let phrase = Input::new() + .with_prompt("Enter recovery phrase (12 or 24 words)") + .interact_text() + .map_err(|e| anyhow::anyhow!("Failed to read recovery phrase: {}", e))?; + if phrase.trim().is_empty() { + anyhow::bail!("Recovery phrase cannot be empty"); + } + Ok(phrase) +} + fn create( name: String, fund: bool, network_override: Option, encrypt: bool, strict: bool, + use_mnemonic: bool, + words: String, + account_index: u32, ) -> Result<()> { let mut cfg = config::load()?; @@ -371,8 +436,22 @@ fn create( let steps = if fund { 3 } else { 2 }; p::header(&format!("Creating wallet '{}'", name)); - p::step(1, steps, "Generating keypair…"); - let (public_key, secret_key) = generate_keypair(); + let (public_key, secret_key) = if use_mnemonic { + let word_count = parse_word_count(&words)?; + p::step( + 1, + steps, + &format!("Generating {}-word recovery phrase…", word_count.as_usize()), + ); + let phrase = mnemonic::generate_phrase(word_count)?; + println!(); + p::warn("Write down this recovery phrase in order. Anyone with it can access your funds."); + p::kv_accent("Recovery Phrase", &phrase); + mnemonic::keypair_from_phrase(&phrase, "", account_index)? + } else { + p::step(1, steps, "Generating keypair…"); + generate_keypair() + }; println!(); p::kv_accent("Public Key", &public_key); @@ -415,6 +494,7 @@ fn create( network: network.clone(), created_at: Utc::now().to_rfc3339(), funded: false, + rotation_history: Vec::new(), }; cfg.wallets.push(wallet); @@ -750,6 +830,76 @@ fn export_wallet(name: String, output: PathBuf) -> Result<()> { Ok(()) } +fn import_wallet( + name: Option, + file: Option, + from_mnemonic: bool, + account_index: u32, + network_override: Option, + encrypt: bool, +) -> Result<()> { + if from_mnemonic { + let name = name.ok_or_else(|| { + anyhow::anyhow!("Wallet name is required for mnemonic import (e.g. starforge wallet import alice --mnemonic)") + })?; + return import_from_mnemonic(name, account_index, network_override, encrypt); + } + + let file = file.ok_or_else(|| { + anyhow::anyhow!("Provide --file or --mnemonic to import a wallet") + })?; + import_wallets(file) +} + +fn import_from_mnemonic( + name: String, + account_index: u32, + network_override: Option, + encrypt: bool, +) -> Result<()> { + let mut cfg = config::load()?; + config::validate_wallet_name(&name)?; + + if cfg.wallets.iter().any(|w| w.name == name) { + anyhow::bail!("A wallet named '{}' already exists.", name); + } + + let network = network_override.unwrap_or_else(|| cfg.network.clone()); + p::header(&format!("Importing wallet '{}' from recovery phrase", name)); + + let phrase = prompt_recovery_phrase()?; + let (public_key, secret_key) = mnemonic::keypair_from_phrase(&phrase, "", account_index)?; + + println!(); + p::kv_accent("Public Key", &public_key); + + let secret_to_store = if encrypt { + println!(); + let pwd = crypto::prompt_passphrase("Set a passphrase to encrypt this wallet", false)?; + crypto::encrypt_secret(&pwd, &secret_key)? + } else { + secret_key + }; + + cfg.wallets.push(config::WalletEntry { + name: name.clone(), + public_key, + secret_key: Some(secret_to_store), + network: network.clone(), + created_at: Utc::now().to_rfc3339(), + funded: false, + rotation_history: Vec::new(), + }); + + config::save(&cfg)?; + p::success(&format!("Wallet '{}' imported from recovery phrase", name)); + p::info(&format!( + "View it with: {}", + format!("starforge wallet show {}", name).cyan() + )); + Ok(()) +} + fn import_wallets(file: PathBuf) -> Result<()> { config::validate_file_path(&file, Some("json"))?; let contents = diff --git a/src/utils/mnemonic.rs b/src/utils/mnemonic.rs new file mode 100644 index 00000000..ab8ac297 --- /dev/null +++ b/src/utils/mnemonic.rs @@ -0,0 +1,147 @@ +use anyhow::{anyhow, Context, Result}; +use bip39::{Language, Mnemonic}; +use ed25519_dalek::SigningKey; +use hmac::{Hmac, Mac}; +use sha2::Sha512; +use stellar_strkey::ed25519::{PrivateKey as StellarPrivateKey, PublicKey as StellarPublicKey}; + +type HmacSha512 = Hmac; + +/// Supported BIP39 mnemonic lengths. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WordCount { + Words12, + Words24, +} + +impl WordCount { + pub fn as_usize(self) -> usize { + match self { + Self::Words12 => 12, + Self::Words24 => 24, + } + } +} + +/// Generate a new BIP39 mnemonic phrase in English. +pub fn generate_phrase(count: WordCount) -> Result { + let mnemonic = Mnemonic::generate_in(Language::English, count.as_usize()) + .map_err(|e| anyhow!("Failed to generate mnemonic: {}", e))?; + Ok(mnemonic.to_string()) +} + +/// Derive a Stellar keypair from a BIP39 phrase (SEP-0005: `m/44'/148'/account'`). +pub fn keypair_from_phrase( + phrase: &str, + bip39_passphrase: &str, + account_index: u32, +) -> Result<(String, String)> { + let mnemonic = Mnemonic::parse_in(Language::English, normalize_phrase(phrase)) + .map_err(|e| anyhow!("Invalid recovery phrase: {}", e))?; + + let word_count = mnemonic.word_count(); + if word_count != 12 && word_count != 24 { + anyhow::bail!( + "Recovery phrase must be 12 or 24 words (got {}).", + word_count + ); + } + + let seed = mnemonic.to_seed(bip39_passphrase); + let private_key = derive_stellar_private_key(&seed, account_index)?; + let signing_key = SigningKey::from_bytes(&private_key); + let verifying_key = signing_key.verifying_key(); + + let public_key = StellarPublicKey(verifying_key.to_bytes()).to_string(); + let secret_key = StellarPrivateKey(private_key).to_string(); + Ok((public_key, secret_key)) +} + +fn normalize_phrase(phrase: &str) -> String { + phrase.split_whitespace().collect::>().join(" ") +} + +/// SLIP-0010 ed25519 derivation for Stellar path `m/44'/148'/account'`. +fn derive_stellar_private_key(seed: &[u8], account_index: u32) -> Result<[u8; 32]> { + let (mut key, mut chain) = slip10_ed25519_master(seed)?; + (key, chain) = slip10_ed25519_child(key, chain, hardened(44))?; + (key, chain) = slip10_ed25519_child(key, chain, hardened(148))?; + (key, _) = slip10_ed25519_child(key, chain, hardened(account_index))?; + Ok(key) +} + +fn hardened(index: u32) -> u32 { + index | 0x8000_0000 +} + +fn slip10_ed25519_master(seed: &[u8]) -> Result<([u8; 32], [u8; 32])> { + let mut mac = HmacSha512::new_from_slice(b"ed25519 seed").context("HMAC init failed")?; + mac.update(seed); + let result = mac.finalize().into_bytes(); + split_512(&result) +} + +fn slip10_ed25519_child( + parent_key: [u8; 32], + parent_chain: [u8; 32], + index: u32, +) -> Result<([u8; 32], [u8; 32])> { + if index < 0x8000_0000 { + anyhow::bail!("Stellar derivation requires hardened path segments"); + } + + let mut mac = HmacSha512::new_from_slice(&parent_chain).context("HMAC init failed")?; + mac.update(&[0x00]); + mac.update(&parent_key); + mac.update(&index.to_be_bytes()); + let result = mac.finalize().into_bytes(); + split_512(&result) +} + +fn split_512(bytes: &[u8]) -> Result<([u8; 32], [u8; 32])> { + let mut left = [0u8; 32]; + let mut right = [0u8; 32]; + left.copy_from_slice(&bytes[..32]); + right.copy_from_slice(&bytes[32..]); + Ok((left, right)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generates_valid_12_and_24_word_phrases() { + for count in [WordCount::Words12, WordCount::Words24] { + let phrase = generate_phrase(count).unwrap(); + let words: Vec<_> = phrase.split_whitespace().collect(); + assert_eq!(words.len(), count.as_usize()); + assert!(Mnemonic::parse_in(Language::English, &phrase).is_ok()); + } + } + + #[test] + fn derivation_is_deterministic() { + let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let (pk1, sk1) = keypair_from_phrase(phrase, "", 0).unwrap(); + let (pk2, sk2) = keypair_from_phrase(phrase, "", 0).unwrap(); + assert_eq!(pk1, pk2); + assert_eq!(sk1, sk2); + assert!(pk1.starts_with('G')); + assert!(sk1.starts_with('S')); + } + + #[test] + fn different_accounts_derive_different_keys() { + let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let (pk0, _) = keypair_from_phrase(phrase, "", 0).unwrap(); + let (pk1, _) = keypair_from_phrase(phrase, "", 1).unwrap(); + assert_ne!(pk0, pk1); + } + + #[test] + fn rejects_invalid_checksum_phrase() { + let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"; + assert!(keypair_from_phrase(phrase, "", 0).is_err()); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index d470f9e6..e5340ec2 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -3,6 +3,7 @@ pub mod crypto; pub mod hardware_wallet; pub mod horizon; pub mod logging; +pub mod mnemonic; pub mod mock_soroban; pub mod multisig; pub mod notifications; @@ -16,5 +17,5 @@ pub mod stream; pub mod telemetry; pub mod templates; pub mod test_runner; -pub mod tx_batch; pub mod tutorial_engine; +pub mod tx_batch;