Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
178 changes: 155 additions & 23 deletions src/commands/wallet.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -62,12 +62,15 @@ pub enum WalletCommands {
/// (requires --encrypt)
#[arg(long, default_value = "false", requires = "encrypt")]
strict: bool,
/// Argon2 memory cost in KiB (requires --encrypt)
#[arg(long, requires = "encrypt")]
mem: Option<u32>,
/// Argon2 iteration count (requires --encrypt)
#[arg(long, requires = "encrypt")]
iterations: Option<u32>,
/// 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,
Expand Down Expand Up @@ -137,11 +140,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<String>,
/// Path to backup JSON file
#[arg(long)]
file: PathBuf,
#[arg(long, group = "source")]
file: Option<PathBuf>,
/// 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<String>,
/// 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
Expand Down Expand Up @@ -245,9 +262,19 @@ pub fn handle(cmd: WalletCommands) -> Result<()> {
network,
encrypt,
strict,
mem,
iterations,
} => create(name, fund, network, encrypt, strict, mem, iterations),
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),
Expand All @@ -269,7 +296,14 @@ pub fn handle(cmd: WalletCommands) -> Result<()> {
iterations,
} => rotate_wallet(name, fund, network, encrypt, mem, iterations),
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),
Expand Down Expand Up @@ -392,12 +426,24 @@ fn generate_keypair() -> (String, String) {
(public_key, secret_key)
}

fn kdf_options(mem: Option<u32>, iterations: Option<u32>) -> Option<crypto::KdfOptions> {
if mem.is_none() && iterations.is_none() {
None
} else {
Some(crypto::KdfOptions { mem, iterations })
fn parse_word_count(words: &str) -> Result<mnemonic::WordCount> {
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<String> {
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(
Expand All @@ -406,8 +452,9 @@ fn create(
network_override: Option<String>,
encrypt: bool,
strict: bool,
mem: Option<u32>,
iterations: Option<u32>,
use_mnemonic: bool,
words: String,
account_index: u32,
) -> Result<()> {
let mut cfg = config::load()?;

Expand All @@ -422,8 +469,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);

Expand Down Expand Up @@ -466,6 +527,7 @@ fn create(
network: network.clone(),
created_at: Utc::now().to_rfc3339(),
funded: false,
rotation_history: Vec::new(),
};
cfg.wallets.push(wallet);

Expand Down Expand Up @@ -1024,6 +1086,76 @@ fn export_wallet(name: String, output: PathBuf) -> Result<()> {
Ok(())
}

fn import_wallet(
name: Option<String>,
file: Option<PathBuf>,
from_mnemonic: bool,
account_index: u32,
network_override: Option<String>,
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 <backup.json> or --mnemonic to import a wallet")
})?;
import_wallets(file)
}

fn import_from_mnemonic(
name: String,
account_index: u32,
network_override: Option<String>,
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 =
Expand Down
Loading
Loading