diff --git a/src/commands/config.rs b/src/commands/config.rs index 3de289ca..8ccfa4f5 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1,120 +1,94 @@ -use anyhow::{bail, Result}; +use crate::utils::{config, crypto, print as p}; +use anyhow::Result; use clap::Subcommand; -use colored::*; #[derive(Subcommand)] pub enum ConfigCommands { - /// Get a configuration value - Get { - /// Configuration key to retrieve (e.g., telemetry, network, wallet) - key: String, - }, - /// Set a configuration value - Set { - /// Configuration key to set (e.g., telemetry) - key: String, - /// Configuration value (e.g., true, false) - value: String, - }, - /// Show all configuration settings + /// Show current global configuration Show, + /// Set global wallet encryption parameters (Argon2id) + SetEncryption { + /// Argon2 memory cost in KiB (e.g. 65536) + #[arg(long)] + mem: Option, + /// Argon2 iteration count (e.g. 3) + #[arg(long)] + iterations: Option, + /// Argon2 parallelism factor (e.g. 4) + #[arg(long)] + parallelism: Option, + /// Reset to library defaults + #[arg(long, default_value = "false")] + reset: bool, + }, } -pub fn handle_config(cmd: ConfigCommands) -> Result<()> { +pub fn handle(cmd: ConfigCommands) -> Result<()> { match cmd { - ConfigCommands::Get { key } => handle_get(&key), - ConfigCommands::Set { key, value } => handle_set(&key, &value), - ConfigCommands::Show => handle_show(), + ConfigCommands::Show => show(), + ConfigCommands::SetEncryption { + mem, + iterations, + parallelism, + reset, + } => set_encryption(mem, iterations, parallelism, reset), } } -fn handle_get(key: &str) -> Result<()> { - let cfg = crate::utils::config::load()?; +fn show() -> Result<()> { + let cfg = config::load()?; + p::header("StarForge Configuration"); + p::separator(); - match key.to_lowercase().as_str() { - "telemetry" => { - let enabled = cfg.telemetry_enabled.unwrap_or(true); - println!("{}: {}", key.cyan(), if enabled { "enabled" } else { "disabled" }); - Ok(()) - } - "network" => { - println!("{}: {}", key.cyan(), cfg.network); - Ok(()) - } - _ => { - bail!( - "Unknown configuration key: '{}'\n\nAvailable keys:\n - telemetry\n - network", - key - ); - } - } -} - -fn handle_set(key: &str, value: &str) -> Result<()> { - let mut cfg = crate::utils::config::load()?; + p::kv("Config file", &config::config_path().display().to_string()); + p::kv("Active network", &cfg.network); + p::kv("Telemetry", if cfg.telemetry_enabled.unwrap_or(false) { "enabled" } else { "disabled" }); - match key.to_lowercase().as_str() { - "telemetry" => { - let enabled = match value.to_lowercase().as_str() { - "true" | "on" | "enabled" | "yes" => true, - "false" | "off" | "disabled" | "no" => false, - _ => { - bail!( - "Invalid value for telemetry: '{}'\n\nUse 'true'/'enabled'/'on'/'yes' or 'false'/'disabled'/'off'/'no'.", - value - ); - } - }; - cfg.telemetry_enabled = Some(enabled); - crate::utils::config::save(&cfg)?; - println!( - "✓ {} set to {}", - "telemetry".green(), - if enabled { "enabled" } else { "disabled" } - ); - Ok(()) - } - "network" => { - crate::utils::config::validate_network(value)?; - cfg.network = value.to_string(); - crate::utils::config::save(&cfg)?; - println!("✓ {} set to {}", "network".green(), value); - Ok(()) - } - _ => { - bail!( - "Unknown configuration key: '{}'\n\nAvailable keys:\n - telemetry\n - network", - key - ); - } + println!(); + p::header("Wallet Encryption (Argon2id)"); + if let Some(kdf) = &cfg.wallet_encryption { + p::kv("Memory cost", &format!("{} KiB", kdf.mem.unwrap_or(32768))); + p::kv("Iterations", &kdf.iterations.unwrap_or(3).to_string()); + p::kv("Parallelism", &kdf.parallelism.unwrap_or(1).to_string()); + } else { + p::info("Using default Argon2id parameters:"); + p::kv("Memory cost", "32768 KiB (default)"); + p::kv("Iterations", "3 (default)"); + p::kv("Parallelism", "1 (default)"); } -} - -fn handle_show() -> Result<()> { - let cfg = crate::utils::config::load()?; - println!("\n{}", "=== StarForge Configuration ===".bold()); - println!(); - println!(" {}: {}", "Network".cyan(), cfg.network); + p::separator(); + Ok(()) +} - let telemetry_status = cfg.telemetry_enabled.unwrap_or(true); - println!( - " {}: {}", - "Telemetry".cyan(), - if telemetry_status { "enabled" } else { "disabled" } - ); +fn set_encryption( + mem: Option, + iterations: Option, + parallelism: Option, + reset: bool, +) -> Result<()> { + let mut cfg = config::load()?; - println!("\n{}", "Configuration file:".cyan()); - if let Ok(cfg_path) = crate::utils::config::get_config_path() { - println!(" {}", cfg_path.display()); + if reset { + cfg.wallet_encryption = None; + config::save(&cfg)?; + p::success("Wallet encryption parameters reset to defaults."); + return Ok(()); } - println!("\n{}", "Data directory:".cyan()); - if let Ok(data_dir) = crate::utils::config::get_data_dir() { - println!(" {}", data_dir.display()); + if mem.is_none() && iterations.is_none() && parallelism.is_none() { + anyhow::bail!("Provide at least one parameter to set (e.g. --mem 65536) or use --reset"); } - println!(); + let mut kdf = cfg.wallet_encryption.unwrap_or_default(); + if let Some(m) = mem { kdf.mem = Some(m); } + if let Some(i) = iterations { kdf.iterations = Some(i); } + if let Some(p) = parallelism { kdf.parallelism = Some(p); } + + cfg.wallet_encryption = Some(kdf); + config::save(&cfg)?; + p::success("Global wallet encryption parameters updated."); + show()?; Ok(()) } diff --git a/src/commands/plugin.rs b/src/commands/plugin.rs index a576a797..a7a1efbb 100644 --- a/src/commands/plugin.rs +++ b/src/commands/plugin.rs @@ -409,7 +409,13 @@ fn update(name: Option, yes: bool) -> Result<()> { match status { Ok(s) if s.success() => { - registry::install_plugin(&pl.name, std::path::Path::new(&pl.path), &pl.source, &pl.starforge_version, &pl.plugin_version)?; + registry::install_plugin( + &pl.name, + std::path::Path::new(&pl.path), + &pl.source, + &pl.starforge_version, + &pl.plugin_version, + )?; p::success(&format!(" '{}' updated via cargo install", pl.name)); updated += 1; } diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index 9295c814..a3b49738 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -13,12 +13,27 @@ use stellar_strkey::ed25519::{PrivateKey as StellarPrivateKey, PublicKey as Stel const WALLET_BACKUP_VERSION: &str = "1"; -fn kdf_options(mem: Option, iterations: Option) -> Option { - if mem.is_none() && iterations.is_none() { - None - } else { - Some(crypto::KdfOptions { mem, iterations }) +fn kdf_options( + mem: Option, + iterations: Option, + parallelism: Option, + config_default: Option<&crypto::KdfOptions>, +) -> Option { + if mem.is_none() && iterations.is_none() && parallelism.is_none() && config_default.is_none() { + return None; + } + + let mut options = config_default.cloned().unwrap_or_default(); + if let Some(m) = mem { + options.mem = Some(m); + } + if let Some(i) = iterations { + options.iterations = Some(i); } + if let Some(p) = parallelism { + options.parallelism = Some(p); + } + Some(options) } #[derive(Debug, Serialize, Deserialize)] @@ -79,6 +94,15 @@ pub enum WalletCommands { /// Account index for SEP-0005 path m/44'/148'/index' (requires --mnemonic) #[arg(long, default_value = "0", requires = "mnemonic")] account_index: u32, + /// Argon2 memory cost in KiB (requires --encrypt) + #[arg(long, requires = "encrypt")] + mem: Option, + /// Argon2 iteration count (requires --encrypt) + #[arg(long, requires = "encrypt")] + iterations: Option, + /// Argon2 parallelism factor (requires --encrypt) + #[arg(long, requires = "encrypt")] + parallelism: Option, }, /// List all saved wallets List, @@ -139,21 +163,9 @@ pub enum WalletCommands { /// Argon2 iteration count (requires --encrypt) #[arg(long, requires = "encrypt")] iterations: Option, - /// Write a pre-rotation backup snapshot to this file before generating - /// the new key. The snapshot is an encrypted JSON file (same format as - /// `wallet export`) containing the current keypair and full rotation - /// history. The previous secret key is also preserved in the in-config - /// rotation history entry so `wallet history` can display it later. - #[arg(long)] - backup: Option, - }, - /// Show the full rotation history for a wallet - History { - /// Wallet name - name: String, - /// Reveal previous secret keys stored in the rotation history - #[arg(long, default_value = "false")] - reveal: bool, + /// Argon2 parallelism factor (requires --encrypt) + #[arg(long, requires = "encrypt")] + parallelism: Option, }, /// Export a wallet to a JSON backup file Export { @@ -295,6 +307,9 @@ pub fn handle(cmd: WalletCommands) -> Result<()> { mnemonic: use_mnemonic, words, account_index, + mem, + iterations, + parallelism, } => create( name, fund, @@ -304,6 +319,9 @@ pub fn handle(cmd: WalletCommands) -> Result<()> { use_mnemonic, words, account_index, + mem, + iterations, + parallelism, ), WalletCommands::List => list(), WalletCommands::Show { name, reveal } => show(name, reveal), @@ -324,9 +342,8 @@ pub fn handle(cmd: WalletCommands) -> Result<()> { encrypt, mem, iterations, - backup, - } => rotate_wallet(name, fund, network, encrypt, mem, iterations, backup), - WalletCommands::History { name, reveal } => wallet_history(name, reveal), + parallelism, + } => rotate_wallet(name, fund, network, encrypt, mem, iterations, parallelism), WalletCommands::Export { name, all, output } => export_wallet(name, all, output), WalletCommands::Import { name, @@ -496,6 +513,9 @@ fn create( use_mnemonic: bool, words: String, account_index: u32, + mem: Option, + iterations: Option, + parallelism: Option, ) -> Result<()> { let mut cfg = config::load()?; @@ -547,7 +567,11 @@ fn create( } println!(); let pwd = crypto::prompt_passphrase("Set a passphrase to encrypt this wallet", strict)?; - crypto::encrypt_secret(&pwd, &secret_key, None)? + crypto::encrypt_secret( + &pwd, + &secret_key, + kdf_options(mem, iterations, parallelism, cfg.wallet_encryption.as_ref()).as_ref(), + )? } else { secret_key.clone() }; @@ -1020,7 +1044,7 @@ fn rotate_wallet( encrypt: bool, mem: Option, iterations: Option, - backup: Option, + parallelism: Option, ) -> Result<()> { config::validate_wallet_name(&name)?; let mut cfg = config::load()?; @@ -1079,7 +1103,11 @@ fn rotate_wallet( "Set a secure passphrase to encrypt the rotated wallet", true, )?; - crypto::encrypt_secret(&pwd, &secret_key, kdf_options(mem, iterations).as_ref())? + crypto::encrypt_secret( + &pwd, + &secret_key, + kdf_options(mem, iterations, parallelism, cfg.wallet_encryption.as_ref()).as_ref(), + )? } else { secret_key.clone() }; @@ -1238,7 +1266,11 @@ fn export_wallet(name_opt: Option, all: bool, output: PathBuf) -> Result let json = serde_json::to_string_pretty(&backup) .with_context(|| "Failed to serialize wallet backup")?; let passphrase = crypto::prompt_passphrase("Enter passphrase to encrypt backup", false)?; - let encrypted = crypto::encrypt_secret(&passphrase, &json, None)?; + let encrypted = crypto::encrypt_secret( + &passphrase, + &json, + kdf_options(None, None, None, cfg.wallet_encryption.as_ref()).as_ref(), + )?; fs::write(&output, encrypted) .with_context(|| format!("Failed to write {}", output.display()))?; @@ -1311,7 +1343,11 @@ fn import_from_mnemonic( 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, None)? + crypto::encrypt_secret( + &pwd, + &secret_key, + kdf_options(None, None, None, cfg.wallet_encryption.as_ref()).as_ref(), + )? } else { secret_key }; diff --git a/src/main.rs b/src/main.rs index ff3207c1..afe382fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,6 +60,10 @@ enum Commands { #[command(subcommand)] Config(commands::config::ConfigCommands), + /// Manage global configuration + #[command(subcommand)] + Config(commands::config::ConfigCommands), + Tx(commands::tx::TxArgs), // fetch transaction for the account /// View or switch the active network (testnet/mainnet) @@ -169,7 +173,7 @@ fn main() { Commands::Inspect(cmd) => commands::inspect::handle(cmd), Commands::Deploy(args) => commands::deploy::handle(args), Commands::Info => commands::info::handle(), - Commands::Config(cmd) => commands::config::handle_config(cmd), + Commands::Config(cmd) => commands::config::handle(cmd), Commands::Tx(args) => commands::tx::handle(args), Commands::Network(cmd) => commands::network::handle(cmd), Commands::Node(cmd) => commands::node::handle(cmd), diff --git a/src/plugins/registry.rs b/src/plugins/registry.rs index 07b5b926..7fb8cab2 100644 --- a/src/plugins/registry.rs +++ b/src/plugins/registry.rs @@ -75,16 +75,9 @@ pub struct InstalledPlugin { /// Plugin version from manifest. #[serde(default)] pub plugin_version: String, - /// Commands registered by this plugin (name → description). + /// RFC3339 timestamp of when the plugin was installed. #[serde(default)] - pub commands: Vec, -} - -/// A command entry persisted in the registry so it is visible without loading the .so. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RegisteredCommand { - pub name: String, - pub description: String, + pub installed_at: Option, } fn registry_path() -> Result { @@ -170,6 +163,8 @@ pub fn install_plugin( } let trust = classify_source(source); + let now = chrono::Utc::now().to_rfc3339(); + let mut reg = load_registry().unwrap_or_default(); reg.plugins.retain(|p| p.name != name); reg.plugins.push(InstalledPlugin { @@ -179,7 +174,7 @@ pub fn install_plugin( trust, starforge_version: starforge_version.to_string(), plugin_version: plugin_version.to_string(), - commands, + installed_at: Some(now), }); reg.plugins.sort_by(|a, b| a.name.cmp(&b.name)); save_registry(®)?; @@ -299,6 +294,15 @@ fn candidate_library_names(name: &str) -> Vec { } } +pub fn get_installed_plugin_version(name: &str) -> Option { + load_registry() + .ok()? + .plugins + .iter() + .find(|p| p.name == name) + .map(|p| p.plugin_version.clone()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/utils/config.rs b/src/utils/config.rs index 00493181..b0bd393a 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -1,3 +1,4 @@ +use crate::utils::crypto; use anyhow::{Context, Result}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use serde::{Deserialize, Serialize}; @@ -99,10 +100,13 @@ pub fn validate_network(network: &str) -> Result<()> { pub fn validate_secret_key(secret: &str) -> Result<()> { if secret.contains(':') { let parts: Vec<&str> = secret.split(':').collect(); - // Accept both 3-part (legacy: salt:nonce:ciphertext) and 5-part (with KDF: salt:nonce:ciphertext:mem:iterations) - if parts.len() != 3 && parts.len() != 5 { + // Accept: + // - 3-part (legacy: salt:nonce:ciphertext) + // - 5-part (KDF without p_cost: salt:nonce:ciphertext:mem:iterations) + // - 6-part (KDF with p_cost: salt:nonce:ciphertext:mem:iterations:parallelism) + if parts.len() != 3 && parts.len() != 5 && parts.len() != 6 { anyhow::bail!( - "Invalid encrypted secret bundle format: expected 3 or 5 parts, got {}", + "Invalid encrypted secret bundle format: expected 3, 5, or 6 parts, got {}", parts.len() ); } @@ -114,8 +118,8 @@ pub fn validate_secret_key(secret: &str) -> Result<()> { })?; } - // If 5-part bundle, validate KDF parameters are valid u32 - if parts.len() == 5 { + // If 5 or 6-part bundle, validate KDF parameters are valid u32 + if parts.len() >= 5 { parts[3] .parse::() .map_err(|_| anyhow::anyhow!("Invalid KDF memory cost: must be a valid u32"))?; @@ -123,6 +127,11 @@ pub fn validate_secret_key(secret: &str) -> Result<()> { .parse::() .map_err(|_| anyhow::anyhow!("Invalid KDF iteration count: must be a valid u32"))?; } + if parts.len() == 6 { + parts[5] + .parse::() + .map_err(|_| anyhow::anyhow!("Invalid KDF parallelism factor: must be a valid u32"))?; + } return Ok(()); } @@ -188,6 +197,7 @@ pub struct Config { #[serde(default)] pub networks: std::collections::HashMap, pub telemetry_enabled: Option, + pub wallet_encryption: Option, } fn default_version() -> String { @@ -264,6 +274,7 @@ impl Default for Config { wallets: vec![], networks, telemetry_enabled: Some(true), + wallet_encryption: None, } } } @@ -484,6 +495,14 @@ mod tests { let cipher = BASE64.encode([2u8; 32]); let bundle = format!("{}:{}:{}", salt, nonce, cipher); assert!(validate_secret_key(&bundle).is_ok()); + + // 5-part + let bundle_5 = format!("{}:{}:{}:32768:4", salt, nonce, cipher); + assert!(validate_secret_key(&bundle_5).is_ok()); + + // 6-part + let bundle_6 = format!("{}:{}:{}:32768:4:2", salt, nonce, cipher); + assert!(validate_secret_key(&bundle_6).is_ok()); } #[test] diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index 715df412..3d61c7de 100644 --- a/src/utils/crypto.rs +++ b/src/utils/crypto.rs @@ -215,19 +215,21 @@ pub fn prompt_passphrase(prompt: &str, strict: bool) -> Result { // ── Argon2 KDF tuning ───────────────────────────────────────────────────────── -/// Optional Argon2 parameters for wallet encryption (`m_cost` / `t_cost`). -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +/// Optional Argon2 parameters for wallet encryption (`m_cost` / `t_cost` / `p_cost`). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct KdfOptions { /// Memory cost in KiB blocks (`m_cost`). Uses the Argon2 default when unset. pub mem: Option, /// Iteration count (`t_cost`). Uses the Argon2 default when unset. pub iterations: Option, + /// Parallelism factor (`p_cost`). Uses the Argon2 default when unset. + pub parallelism: Option, } impl KdfOptions { - /// True when both fields are unset (library defaults apply). + /// True when all fields are unset (library defaults apply). pub fn is_default(&self) -> bool { - self.mem.is_none() && self.iterations.is_none() + self.mem.is_none() && self.iterations.is_none() && self.parallelism.is_none() } } @@ -239,7 +241,10 @@ fn resolve_params(options: Option<&KdfOptions>) -> Result { let t_cost = options .and_then(|o| o.iterations) .unwrap_or_else(|| defaults.t_cost()); - Params::new(m_cost, t_cost, defaults.p_cost(), None) + let p_cost = options + .and_then(|o| o.parallelism) + .unwrap_or_else(|| defaults.p_cost()); + Params::new(m_cost, t_cost, p_cost, None) .map_err(|e| anyhow!("Invalid Argon2 parameters: {}", e)) } @@ -273,6 +278,31 @@ fn parse_encrypted_bundle(bundle: &str) -> Result<(Vec, Vec, Vec, Op Some(KdfOptions { mem: Some(mem), iterations: Some(iterations), + parallelism: None, + }), + )) + } + 6 => { + let salt = BASE64.decode(parts[0])?; + let nonce_bytes = BASE64.decode(parts[1])?; + let ciphertext = BASE64.decode(parts[2])?; + let mem = parts[3] + .parse::() + .map_err(|_| anyhow!("Invalid encrypted bundle: bad mem cost"))?; + let iterations = parts[4] + .parse::() + .map_err(|_| anyhow!("Invalid encrypted bundle: bad iteration count"))?; + let parallelism = parts[5] + .parse::() + .map_err(|_| anyhow!("Invalid encrypted bundle: bad parallelism factor"))?; + Ok(( + salt, + nonce_bytes, + ciphertext, + Some(KdfOptions { + mem: Some(mem), + iterations: Some(iterations), + parallelism: Some(parallelism), }), )) } @@ -329,12 +359,13 @@ pub fn encrypt_secret(password: &str, secret: &str, kdf: Option<&KdfOptions>) -> )) } else { Ok(format!( - "{}:{}:{}:{}:{}", + "{}:{}:{}:{}:{}:{}", encoded_salt, encoded_nonce, encoded_cipher, params.m_cost(), - params.t_cost() + params.t_cost(), + params.p_cost() )) } } @@ -455,11 +486,12 @@ mod tests { let kdf = KdfOptions { mem: Some(32_768), iterations: Some(4), + parallelism: Some(2), }; let encrypted = encrypt_secret(password, secret, Some(&kdf)).unwrap(); let parts: Vec<&str> = encrypted.split(':').collect(); - assert_eq!(parts.len(), 5, "expected mem/iterations in bundle"); + assert_eq!(parts.len(), 6, "expected mem/iterations/parallelism in bundle"); let decrypted = decrypt_secret(password, &encrypted).unwrap(); assert_eq!(secret, decrypted); diff --git a/src/utils/horizon.rs b/src/utils/horizon.rs index aa27d8a1..8088e2b4 100644 --- a/src/utils/horizon.rs +++ b/src/utils/horizon.rs @@ -566,6 +566,7 @@ mod tests { networks, wallets: Vec::new(), telemetry_enabled: Some(false), + wallet_encryption: None, }) .expect("save config");