From 3396473734a19fc7eb3a00850467c6b50eb6ba23 Mon Sep 17 00:00:00 2001 From: Enemuo Chimzuruoke Date: Tue, 2 Jun 2026 01:47:16 +0100 Subject: [PATCH] feat: allow configurable wallet encryption parameters (Argon2id) Exposes memory cost, iterations, and parallelism in CLI config and per-command flags. Also fixes several pre-existing build errors in plugin and template modules. --- src/commands/config.rs | 94 ++++++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/commands/plugin.rs | 10 ++++- src/commands/template.rs | 4 ++ src/commands/wallet.rs | 74 ++++++++++++++++++++++++++----- src/main.rs | 6 +++ src/plugins/registry.rs | 21 ++++++--- src/utils/config.rs | 29 ++++++++++--- src/utils/crypto.rs | 48 ++++++++++++++++---- src/utils/horizon.rs | 1 + src/utils/templates.rs | 4 ++ 11 files changed, 261 insertions(+), 31 deletions(-) create mode 100644 src/commands/config.rs diff --git a/src/commands/config.rs b/src/commands/config.rs new file mode 100644 index 00000000..8ccfa4f5 --- /dev/null +++ b/src/commands/config.rs @@ -0,0 +1,94 @@ +use crate::utils::{config, crypto, print as p}; +use anyhow::Result; +use clap::Subcommand; + +#[derive(Subcommand)] +pub enum ConfigCommands { + /// 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(cmd: ConfigCommands) -> Result<()> { + match cmd { + ConfigCommands::Show => show(), + ConfigCommands::SetEncryption { + mem, + iterations, + parallelism, + reset, + } => set_encryption(mem, iterations, parallelism, reset), + } +} + +fn show() -> Result<()> { + let cfg = config::load()?; + p::header("StarForge Configuration"); + p::separator(); + + 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" }); + + 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)"); + } + + p::separator(); + Ok(()) +} + +fn set_encryption( + mem: Option, + iterations: Option, + parallelism: Option, + reset: bool, +) -> Result<()> { + let mut cfg = config::load()?; + + if reset { + cfg.wallet_encryption = None; + config::save(&cfg)?; + p::success("Wallet encryption parameters reset to defaults."); + return Ok(()); + } + + 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"); + } + + 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/mod.rs b/src/commands/mod.rs index ad090895..d11c76f6 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod benchmark; pub mod completions; +pub mod config; pub mod contract; pub mod deploy; pub mod gas; diff --git a/src/commands/plugin.rs b/src/commands/plugin.rs index e015d848..8a73578d 100644 --- a/src/commands/plugin.rs +++ b/src/commands/plugin.rs @@ -356,7 +356,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)?; + 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; } @@ -401,6 +407,8 @@ fn update(name: Option, yes: bool) -> Result<()> { &pl.name, std::path::Path::new(&pl.path), &pl.source, + &pl.starforge_version, + &pl.plugin_version, )?; p::success(&format!( " '{}' library on disk is newer — registry refreshed.", diff --git a/src/commands/template.rs b/src/commands/template.rs index 26bbe2f4..81567b90 100644 --- a/src/commands/template.rs +++ b/src/commands/template.rs @@ -184,6 +184,10 @@ fn install( version, cli_version_min, cli_version_max, + None, + None, + None, + None, )?; p::header("Template Install"); p::info("Template package installed into the local registry."); diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index e7288af2..afd5105a 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,6 +163,9 @@ pub enum WalletCommands { /// Argon2 iteration count (requires --encrypt) #[arg(long, requires = "encrypt")] iterations: Option, + /// Argon2 parallelism factor (requires --encrypt) + #[arg(long, requires = "encrypt")] + parallelism: Option, }, /// Export a wallet to a JSON backup file Export { @@ -277,6 +304,9 @@ pub fn handle(cmd: WalletCommands) -> Result<()> { mnemonic: use_mnemonic, words, account_index, + mem, + iterations, + parallelism, } => create( name, fund, @@ -286,6 +316,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), @@ -306,7 +339,8 @@ pub fn handle(cmd: WalletCommands) -> Result<()> { encrypt, mem, iterations, - } => rotate_wallet(name, fund, network, encrypt, mem, iterations), + parallelism, + } => rotate_wallet(name, fund, network, encrypt, mem, iterations, parallelism), WalletCommands::Export { name, all, output } => export_wallet(name, all, output), WalletCommands::Import { name, @@ -467,6 +501,9 @@ fn create( use_mnemonic: bool, words: String, account_index: u32, + mem: Option, + iterations: Option, + parallelism: Option, ) -> Result<()> { let mut cfg = config::load()?; @@ -518,7 +555,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() }; @@ -978,6 +1019,7 @@ fn rotate_wallet( encrypt: bool, mem: Option, iterations: Option, + parallelism: Option, ) -> Result<()> { config::validate_wallet_name(&name)?; let mut cfg = config::load()?; @@ -1005,7 +1047,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() }; @@ -1100,7 +1146,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()))?; @@ -1161,7 +1211,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 490c5680..a7557f12 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,6 +57,10 @@ enum Commands { /// Show starforge config and environment info Info, + /// 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) @@ -129,6 +133,7 @@ fn main() { Commands::Inspect(_) => "inspect", Commands::Deploy(_) => "deploy", Commands::Info => "info", + Commands::Config(_) => "config", Commands::Tx(_) => "tx", Commands::Network(_) => "network", Commands::Node(_) => "node", @@ -155,6 +160,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(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 b9c2079f..0b7fe618 100644 --- a/src/plugins/registry.rs +++ b/src/plugins/registry.rs @@ -75,6 +75,9 @@ pub struct InstalledPlugin { /// Plugin version from manifest. #[serde(default)] pub plugin_version: String, + /// RFC3339 timestamp of when the plugin was installed. + #[serde(default)] + pub installed_at: Option, } fn registry_path() -> Result { @@ -158,15 +161,9 @@ pub fn install_plugin( } let trust = classify_source(source); - let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + let now = chrono::Utc::now().to_rfc3339(); let mut reg = load_registry().unwrap_or_default(); - // Preserve existing version metadata when re-installing. - let existing_version = reg - .plugins - .iter() - .find(|p| p.name == name) - .and_then(|p| p.version.clone()); reg.plugins.retain(|p| p.name != name); reg.plugins.push(InstalledPlugin { name: name.to_string(), @@ -175,6 +172,7 @@ pub fn install_plugin( trust, starforge_version: starforge_version.to_string(), plugin_version: plugin_version.to_string(), + installed_at: Some(now), }); reg.plugins.sort_by(|a, b| a.name.cmp(&b.name)); save_registry(®)?; @@ -284,6 +282,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 2cbff17e..67ebe2b6 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 { @@ -260,6 +270,7 @@ impl Default for Config { wallets: vec![], networks, telemetry_enabled: Some(true), + wallet_encryption: None, } } } @@ -476,6 +487,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"); diff --git a/src/utils/templates.rs b/src/utils/templates.rs index cbd4a253..8c8e61c1 100644 --- a/src/utils/templates.rs +++ b/src/utils/templates.rs @@ -968,6 +968,10 @@ pub fn install_template_package( version, cli_version_min, cli_version_max, + None, + None, + None, + None, ) }