From 05a728c2957f9a2063733f7e5f91e0424a8eb0c5 Mon Sep 17 00:00:00 2001 From: smypmsa Date: Wed, 25 Feb 2026 10:53:01 +0000 Subject: [PATCH 1/4] feat: encrypt private keys at rest using Alloy's built-in keystore methods Private keys were stored as plaintext in config.json. Now encrypted using LocalSigner::encrypt_keystore (AES-128-CTR + scrypt) via Alloy's signer-keystore feature. No new crypto crates added. - Password-protected keystore.json replaces plaintext key in config.json - New wallet export command to decrypt and print key for backup - Auto-migration from plaintext config on first use after upgrade - POLYMARKET_PASSWORD env var for non-interactive use (scripts/CI) - 3-retry on wrong password, hidden terminal input via rpassword Closes Polymarket/polymarket-cli#18 Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 131 +++++++++++++++++++++++++++++++++- Cargo.toml | 4 +- src/auth.rs | 42 +++++++++-- src/commands/setup.rs | 6 +- src/commands/wallet.rs | 62 ++++++++++++---- src/config.rs | 148 ++++++++++++++++++++++++++++++++++++++- src/main.rs | 1 + src/password.rs | 48 +++++++++++++ tests/cli_integration.rs | 4 +- 9 files changed, 420 insertions(+), 26 deletions(-) create mode 100644 src/password.rs diff --git a/Cargo.lock b/Cargo.lock index d49183a..1a90189 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.7.8" @@ -472,6 +483,7 @@ dependencies = [ "alloy-primitives", "alloy-signer", "async-trait", + "eth-keystore", "k256", "rand 0.8.5", "thiserror 2.0.18", @@ -1235,6 +1247,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.60" @@ -1440,6 +1462,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.21.3" @@ -1762,6 +1793,28 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "eth-keystore" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fda3bf123be441da5260717e0661c25a2fd9cb2b2c1d20bf2e05580047158ab" +dependencies = [ + "aes", + "ctr", + "digest 0.10.7", + "hex", + "hmac", + "pbkdf2", + "rand 0.8.5", + "scrypt", + "serde", + "serde_json", + "sha2", + "sha3", + "thiserror 1.0.69", + "uuid 0.8.2", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1798,7 +1851,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2430,6 +2483,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2863,6 +2925,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2976,6 +3047,8 @@ dependencies = [ "dirs", "polymarket-client-sdk", "predicates", + "rand 0.8.5", + "rpassword", "rust_decimal", "rust_decimal_macros", "rustyline", @@ -3014,7 +3087,7 @@ dependencies = [ "sha2", "strum_macros", "url", - "uuid", + "uuid 1.21.0", ] [[package]] @@ -3540,7 +3613,7 @@ dependencies = [ "rkyv_derive", "seahash", "tinyvec", - "uuid", + "uuid 1.21.0", ] [[package]] @@ -3564,6 +3637,27 @@ dependencies = [ "rustc-hex", ] +[[package]] +name = "rpassword" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.59.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "ruint" version = "1.17.2" @@ -3789,6 +3883,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3837,6 +3940,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d" +dependencies = [ + "hmac", + "pbkdf2", + "salsa20", + "sha2", +] + [[package]] name = "seahash" version = "4.1.0" @@ -4681,6 +4796,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.17", + "serde", +] + [[package]] name = "uuid" version = "1.21.0" diff --git a/Cargo.toml b/Cargo.toml index b7d516f..3196ecb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" [dependencies] polymarket-client-sdk = { version = "0.4", features = ["gamma", "data", "bridge", "clob", "ctf"] } -alloy = { version = "1.6.3", default-features = false, features = ["providers", "sol-types", "contract", "reqwest", "reqwest-rustls-tls", "signer-local", "signers"] } +alloy = { version = "1.6.3", default-features = false, features = ["providers", "sol-types", "contract", "reqwest", "reqwest-rustls-tls", "signer-local", "signers", "signer-keystore"] } clap = { version = "4", features = ["derive"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } serde_json = "1" @@ -26,6 +26,8 @@ anyhow = "1" chrono = "0.4" dirs = "6" rustyline = "15" +rpassword = "7" +rand = "0.8" [dev-dependencies] assert_cmd = "2" diff --git a/src/auth.rs b/src/auth.rs index 15ad61e..c0842d9 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -19,11 +19,46 @@ fn parse_signature_type(s: &str) -> SignatureType { } } +/// Resolve the private key hex string, prompting for password if needed. +pub(crate) fn resolve_key_string(private_key: Option<&str>) -> Result { + // Auto-migrate plaintext config to encrypted keystore + if config::needs_migration() { + eprintln!("Your wallet key is stored in plaintext. Encrypting it now..."); + let password = crate::password::prompt_new_password()?; + config::migrate_to_encrypted(&password)?; + eprintln!("Wallet key encrypted successfully."); + return config::load_key_encrypted(&password); + } + + // 1. CLI flag + if let Some(key) = private_key { + return Ok(key.to_string()); + } + // 2. Env var + if let Ok(key) = std::env::var(config::ENV_VAR) + && !key.is_empty() + { + return Ok(key); + } + // 3. Old config (plaintext — for backward compat) + if let Some(cfg) = config::load_config() + && !cfg.private_key.is_empty() + { + return Ok(cfg.private_key); + } + // 4. Encrypted keystore with retry + if config::keystore_exists() { + return crate::password::prompt_password_with_retries(|pw| { + config::load_key_encrypted(pw) + }); + } + anyhow::bail!("{}", config::NO_WALLET_MSG) +} + pub fn resolve_signer( private_key: Option<&str>, ) -> Result { - let (key, _) = config::resolve_key(private_key); - let key = key.ok_or_else(|| anyhow::anyhow!("{}", config::NO_WALLET_MSG))?; + let key = resolve_key_string(private_key)?; LocalSigner::from_str(&key) .context("Invalid private key") .map(|s| s.with_chain_id(Some(POLYGON))) @@ -61,8 +96,7 @@ pub async fn create_readonly_provider() -> Result, ) -> Result { - let (key, _) = config::resolve_key(private_key); - let key = key.ok_or_else(|| anyhow::anyhow!("{}", config::NO_WALLET_MSG))?; + let key = resolve_key_string(private_key)?; let signer = LocalSigner::from_str(&key) .context("Invalid private key")? .with_chain_id(Some(POLYGON)); diff --git a/src/commands/setup.rs b/src/commands/setup.rs index dd04671..dec93d2 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -132,7 +132,9 @@ fn setup_wallet() -> Result
{ (address, hex) }; - config::save_wallet(&key_hex, POLYGON, config::DEFAULT_SIGNATURE_TYPE)?; + let password = crate::password::prompt_new_password()?; + config::save_key_encrypted(&key_hex, &password)?; + config::save_wallet_settings(POLYGON, config::DEFAULT_SIGNATURE_TYPE)?; if has_key { println!(" ✓ Wallet imported"); @@ -144,7 +146,7 @@ fn setup_wallet() -> Result
{ if !has_key { println!(); - println!(" ⚠ Back up your private key from the config file."); + println!(" ⚠ Remember your password. Use `polymarket wallet export` to back up your key."); println!(" If lost, your funds cannot be recovered."); } diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index ce7597f..9c3a290 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -48,6 +48,8 @@ pub enum WalletCommand { #[arg(long)] force: bool, }, + /// Export the private key (decrypts keystore, prints to stdout) + Export, } pub fn execute( @@ -68,14 +70,14 @@ pub fn execute( WalletCommand::Address => cmd_address(output, private_key_flag), WalletCommand::Show => cmd_show(output, private_key_flag), WalletCommand::Reset { force } => cmd_reset(output, force), + WalletCommand::Export => cmd_export(output), } } fn guard_overwrite(force: bool) -> Result<()> { - if !force && config::config_exists() { + if !force && (config::config_exists() || config::keystore_exists()) { bail!( - "A wallet already exists at {}. Use --force to overwrite.", - config::config_path()?.display() + "A wallet already exists. Use --force to overwrite.", ); } Ok(()) @@ -101,7 +103,9 @@ fn cmd_create(output: &OutputFormat, force: bool, signature_type: &str) -> Resul write!(key_hex, "{b:02x}").unwrap(); } - config::save_wallet(&key_hex, POLYGON, signature_type)?; + let password = crate::password::prompt_new_password()?; + config::save_key_encrypted(&key_hex, &password)?; + config::save_wallet_settings(POLYGON, signature_type)?; let config_path = config::config_path()?; let proxy_addr = derive_proxy_wallet(address, POLYGON); @@ -126,8 +130,8 @@ fn cmd_create(output: &OutputFormat, force: bool, signature_type: &str) -> Resul println!("Signature type: {signature_type}"); println!("Config: {}", config_path.display()); println!(); - println!("IMPORTANT: Back up your private key from the config file."); - println!(" If lost, your funds cannot be recovered."); + println!("IMPORTANT: Remember your password. Use `polymarket wallet export`"); + println!(" to back up your private key. If lost, funds cannot be recovered."); } } Ok(()) @@ -142,7 +146,9 @@ fn cmd_import(key: &str, output: &OutputFormat, force: bool, signature_type: &st .with_chain_id(Some(POLYGON)); let address = signer.address(); - config::save_wallet(&normalized, POLYGON, signature_type)?; + let password = crate::password::prompt_new_password()?; + config::save_key_encrypted(&normalized, &password)?; + config::save_wallet_settings(POLYGON, signature_type)?; let config_path = config::config_path()?; let proxy_addr = derive_proxy_wallet(address, POLYGON); @@ -172,8 +178,7 @@ fn cmd_import(key: &str, output: &OutputFormat, force: bool, signature_type: &st } fn cmd_address(output: &OutputFormat, private_key_flag: Option<&str>) -> Result<()> { - let (key, _) = config::resolve_key(private_key_flag); - let key = key.ok_or_else(|| anyhow::anyhow!("{}", config::NO_WALLET_MSG))?; + let key = crate::auth::resolve_key_string(private_key_flag)?; let signer = LocalSigner::from_str(&key).context("Invalid private key")?; let address = signer.address(); @@ -190,8 +195,21 @@ fn cmd_address(output: &OutputFormat, private_key_flag: Option<&str>) -> Result< } fn cmd_show(output: &OutputFormat, private_key_flag: Option<&str>) -> Result<()> { - let (key, source) = config::resolve_key(private_key_flag); - let signer = key.as_deref().and_then(|k| LocalSigner::from_str(k).ok()); + let (key_result, source) = { + let (old_key, old_source) = config::resolve_key(private_key_flag); + if old_key.as_ref().is_some_and(|k| !k.is_empty()) { + (Ok(old_key.unwrap()), old_source) + } else if config::keystore_exists() { + let result = crate::password::prompt_password_with_retries(|pw| { + config::load_key_encrypted(pw) + }); + (result, config::KeySource::ConfigFile) + } else { + (Err(anyhow::anyhow!("not configured")), config::KeySource::None) + } + }; + + let signer = key_result.ok().and_then(|k| LocalSigner::from_str(&k).ok()); let address = signer.as_ref().map(|s| s.address().to_string()); let proxy_addr = signer .as_ref() @@ -231,8 +249,28 @@ fn cmd_show(output: &OutputFormat, private_key_flag: Option<&str>) -> Result<()> Ok(()) } +fn cmd_export(output: &OutputFormat) -> Result<()> { + if !config::keystore_exists() { + bail!("{}", config::NO_WALLET_MSG); + } + + let key = crate::password::prompt_password_with_retries(|pw| { + config::load_key_encrypted(pw) + })?; + + match output { + OutputFormat::Json => { + println!("{}", serde_json::json!({"private_key": key})); + } + OutputFormat::Table => { + println!("{key}"); + } + } + Ok(()) +} + fn cmd_reset(output: &OutputFormat, force: bool) -> Result<()> { - if !config::config_exists() { + if !config::config_exists() && !config::keystore_exists() { match output { OutputFormat::Table => println!("Nothing to reset. No config found."), OutputFormat::Json => { diff --git a/src/config.rs b/src/config.rs index d2f5395..9336ed4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -const ENV_VAR: &str = "POLYMARKET_PRIVATE_KEY"; +pub const ENV_VAR: &str = "POLYMARKET_PRIVATE_KEY"; const SIG_TYPE_ENV_VAR: &str = "POLYMARKET_SIGNATURE_TYPE"; pub const DEFAULT_SIGNATURE_TYPE: &str = "proxy"; @@ -13,6 +13,7 @@ pub const NO_WALLET_MSG: &str = #[derive(Serialize, Deserialize)] pub struct Config { + #[serde(default, skip_serializing_if = "String::is_empty")] pub private_key: String, pub chain_id: u64, #[serde(default = "default_signature_type")] @@ -84,7 +85,24 @@ pub fn resolve_signature_type(cli_flag: Option<&str>) -> String { DEFAULT_SIGNATURE_TYPE.to_string() } -pub fn save_wallet(key: &str, chain_id: u64, signature_type: &str) -> Result<()> { +pub fn keystore_path() -> Result { + Ok(config_dir()?.join("keystore.json")) +} + +pub fn keystore_exists() -> bool { + keystore_path().is_ok_and(|p| p.exists()) +} + +/// Returns true if old-format config has a plaintext private_key field but no keystore. +pub fn needs_migration() -> bool { + load_config().is_some_and(|c| !c.private_key.is_empty()) + && !keystore_exists() +} + +/// Encrypt a private key and save as keystore.json. +pub fn save_key_encrypted(key_hex: &str, password: &str) -> Result<()> { + use std::str::FromStr; + let dir = config_dir()?; fs::create_dir_all(&dir).context("Failed to create config directory")?; @@ -94,8 +112,80 @@ pub fn save_wallet(key: &str, chain_id: u64, signature_type: &str) -> Result<()> fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?; } + let signer = alloy::signers::local::LocalSigner::from_str(key_hex) + .map_err(|e| anyhow::anyhow!("Invalid private key: {e}"))?; + let key_bytes = signer.credential().to_bytes(); + + let mut rng = rand::thread_rng(); + alloy::signers::local::LocalSigner::encrypt_keystore( + &dir, &mut rng, key_bytes, password, Some("keystore"), + ) + .map_err(|e| anyhow::anyhow!("Failed to encrypt keystore: {e}"))?; + + // eth-keystore writes to dir/keystore — rename to keystore.json + let written = dir.join("keystore"); + let target = dir.join("keystore.json"); + if written.exists() && written != target { + fs::rename(&written, &target) + .context("Failed to rename keystore file")?; + } + + // Set restrictive permissions on keystore file + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&target, fs::Permissions::from_mode(0o600))?; + } + + Ok(()) +} + +/// Decrypt keystore.json and return the private key as 0x-prefixed hex. +pub fn load_key_encrypted(password: &str) -> Result { + use std::fmt::Write as _; + + let path = keystore_path()?; + let signer = alloy::signers::local::LocalSigner::decrypt_keystore(&path, password) + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("Mac Mismatch") { + anyhow::anyhow!("Wrong password") + } else { + anyhow::anyhow!("Failed to decrypt keystore: {e}") + } + })?; + + let bytes = signer.credential().to_bytes(); + let mut hex = String::with_capacity(2 + bytes.len() * 2); + hex.push_str("0x"); + for b in &bytes { + write!(hex, "{b:02x}").unwrap(); + } + Ok(hex) +} + +/// Migrate old plaintext config to encrypted keystore. +pub fn migrate_to_encrypted(password: &str) -> Result<()> { + let config = load_config() + .ok_or_else(|| anyhow::anyhow!("No config file found to migrate"))?; + + if config.private_key.is_empty() { + anyhow::bail!("No private key found in config to migrate"); + } + + // Encrypt the key + save_key_encrypted(&config.private_key, password)?; + + // Rewrite config.json without private_key + save_wallet_settings(config.chain_id, &config.signature_type)?; + + Ok(()) +} + +/// Save only non-sensitive settings to config.json (no private key). +pub fn save_wallet_settings(chain_id: u64, signature_type: &str) -> Result<()> { let config = Config { - private_key: key.to_string(), + private_key: String::new(), chain_id, signature_type: signature_type.to_string(), }; @@ -209,4 +299,56 @@ mod tests { let result = resolve_signature_type(None); assert!(!result.is_empty()); } + + #[test] + fn keystore_encrypt_decrypt_round_trip() { + use std::str::FromStr; + + let temp = std::env::temp_dir().join("polymarket_test_keystore"); + let _ = fs::remove_dir_all(&temp); + fs::create_dir_all(&temp).unwrap(); + + let key_hex = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let password = "test_password_123"; + + let original = alloy::signers::local::LocalSigner::from_str(key_hex).unwrap(); + + let mut rng = rand::thread_rng(); + alloy::signers::local::LocalSigner::encrypt_keystore( + &temp, &mut rng, original.credential().to_bytes(), password, Some("test_ks"), + ) + .unwrap(); + + let recovered = + alloy::signers::local::LocalSigner::decrypt_keystore(temp.join("test_ks"), password) + .unwrap(); + assert_eq!(original.address(), recovered.address()); + + let _ = fs::remove_dir_all(&temp); + } + + #[test] + fn keystore_wrong_password_fails() { + use std::str::FromStr; + + let temp = std::env::temp_dir().join("polymarket_test_keystore_fail"); + let _ = fs::remove_dir_all(&temp); + fs::create_dir_all(&temp).unwrap(); + + let signer = alloy::signers::local::LocalSigner::from_str( + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ) + .unwrap(); + let mut rng = rand::thread_rng(); + alloy::signers::local::LocalSigner::encrypt_keystore( + &temp, &mut rng, signer.credential().to_bytes(), "correct", Some("test_ks2"), + ) + .unwrap(); + + let result = + alloy::signers::local::LocalSigner::decrypt_keystore(temp.join("test_ks2"), "wrong"); + assert!(result.is_err()); + + let _ = fs::remove_dir_all(&temp); + } } diff --git a/src/main.rs b/src/main.rs index 61af087..7ec098f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod auth; mod commands; mod config; mod output; +mod password; mod shell; use std::process::ExitCode; diff --git a/src/password.rs b/src/password.rs new file mode 100644 index 0000000..76bc8bf --- /dev/null +++ b/src/password.rs @@ -0,0 +1,48 @@ +use anyhow::{Result, bail}; + +const PASSWORD_ENV_VAR: &str = "POLYMARKET_PASSWORD"; + +/// Prompt for password, or read from POLYMARKET_PASSWORD env var. +pub fn prompt_password(prompt_msg: &str) -> Result { + if let Ok(pw) = std::env::var(PASSWORD_ENV_VAR) + && !pw.is_empty() + { + return Ok(pw); + } + rpassword::prompt_password(prompt_msg).map_err(Into::into) +} + +/// Prompt for password with confirmation (for create/import). +pub fn prompt_new_password() -> Result { + let pw = prompt_password("Enter password to encrypt wallet: ")?; + if pw.is_empty() { + bail!("Password cannot be empty"); + } + let confirm = prompt_password("Confirm password: ")?; + if pw != confirm { + bail!("Passwords do not match"); + } + Ok(pw) +} + +/// Prompt for password with up to 3 retries, calling `try_fn` each time. +/// Returns the result of the first successful call to `try_fn`. +pub fn prompt_password_with_retries(try_fn: F) -> Result +where + F: Fn(&str) -> Result, +{ + for attempt in 1..=3 { + let pw = prompt_password("Enter wallet password: ")?; + match try_fn(&pw) { + Ok(val) => return Ok(val), + Err(e) => { + if attempt < 3 { + eprintln!("Wrong password. Try again. ({attempt}/3)"); + } else { + return Err(e); + } + } + } + } + unreachable!() +} diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 41d3d11..317d3c0 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -7,6 +7,7 @@ fn polymarket() -> Command { let mut cmd = Command::cargo_bin("polymarket").unwrap(); cmd.env_remove("POLYMARKET_PRIVATE_KEY"); cmd.env_remove("POLYMARKET_SIGNATURE_TYPE"); + cmd.env_remove("POLYMARKET_PASSWORD"); cmd } @@ -79,7 +80,8 @@ fn wallet_help_lists_subcommands() { .and(predicate::str::contains("import")) .and(predicate::str::contains("address")) .and(predicate::str::contains("show")) - .and(predicate::str::contains("reset")), + .and(predicate::str::contains("reset")) + .and(predicate::str::contains("export")), ); } From edb120018c7ab16bd55a91d717d79f37dd3f6a79 Mon Sep 17 00:00:00 2001 From: smypmsa Date: Wed, 25 Feb 2026 11:14:26 +0000 Subject: [PATCH 2/4] fix: address Cursor Bugbot review findings - Move CLI flag and env var checks before migration block in resolve_key_string so --private-key is never overridden by migration - Add Keystore variant to KeySource so wallet show reports "encrypted keystore" instead of "config file" - Setup wizard now checks keystore_exists() and decrypts to detect existing wallet, preventing silent overwrite of keystore.json Co-Authored-By: Claude Opus 4.6 --- src/auth.rs | 20 ++++++++++---------- src/commands/setup.rs | 33 +++++++++++++++++++++++++++------ src/commands/wallet.rs | 2 +- src/config.rs | 2 ++ 4 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index c0842d9..584a996 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -21,16 +21,7 @@ fn parse_signature_type(s: &str) -> SignatureType { /// Resolve the private key hex string, prompting for password if needed. pub(crate) fn resolve_key_string(private_key: Option<&str>) -> Result { - // Auto-migrate plaintext config to encrypted keystore - if config::needs_migration() { - eprintln!("Your wallet key is stored in plaintext. Encrypting it now..."); - let password = crate::password::prompt_new_password()?; - config::migrate_to_encrypted(&password)?; - eprintln!("Wallet key encrypted successfully."); - return config::load_key_encrypted(&password); - } - - // 1. CLI flag + // 1. CLI flag (highest priority — never overridden by migration) if let Some(key) = private_key { return Ok(key.to_string()); } @@ -40,6 +31,15 @@ pub(crate) fn resolve_key_string(private_key: Option<&str>) -> Result { { return Ok(key); } + + // Auto-migrate plaintext config to encrypted keystore + if config::needs_migration() { + eprintln!("Your wallet key is stored in plaintext. Encrypting it now..."); + let password = crate::password::prompt_new_password()?; + config::migrate_to_encrypted(&password)?; + eprintln!("Wallet key encrypted successfully."); + return config::load_key_encrypted(&password); + } // 3. Old config (plaintext — for backward compat) if let Some(cfg) = config::load_config() && !cfg.private_key.is_empty() diff --git a/src/commands/setup.rs b/src/commands/setup.rs index dec93d2..75eb751 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -84,12 +84,33 @@ pub fn execute() -> Result<()> { step_header(1, total, "Wallet"); - let address = if config::config_exists() { - let (key, source) = config::resolve_key(None); - if let Some(k) = &key - && let Ok(signer) = LocalSigner::from_str(k) - { - let addr = signer.address(); + let address = if config::config_exists() || config::keystore_exists() { + // Try plaintext config first, then encrypted keystore + let existing_addr = { + let (key, _) = config::resolve_key(None); + key.as_deref() + .and_then(|k| LocalSigner::from_str(k).ok()) + .map(|s| s.address()) + } + .or_else(|| { + if config::keystore_exists() { + crate::password::prompt_password_with_retries(|pw| { + config::load_key_encrypted(pw) + }) + .ok() + .and_then(|k| LocalSigner::from_str(&k).ok()) + .map(|s| s.address()) + } else { + None + } + }); + + if let Some(addr) = existing_addr { + let source = if config::keystore_exists() { + config::KeySource::Keystore + } else { + config::KeySource::ConfigFile + }; println!(" ✓ Wallet already configured ({})", source.label()); println!(" Address: {addr}"); println!(); diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index 9c3a290..a1baf79 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -203,7 +203,7 @@ fn cmd_show(output: &OutputFormat, private_key_flag: Option<&str>) -> Result<()> let result = crate::password::prompt_password_with_retries(|pw| { config::load_key_encrypted(pw) }); - (result, config::KeySource::ConfigFile) + (result, config::KeySource::Keystore) } else { (Err(anyhow::anyhow!("not configured")), config::KeySource::None) } diff --git a/src/config.rs b/src/config.rs index 9336ed4..dbe4cbb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,7 @@ pub enum KeySource { Flag, EnvVar, ConfigFile, + Keystore, None, } @@ -37,6 +38,7 @@ impl KeySource { Self::Flag => "--private-key flag", Self::EnvVar => "POLYMARKET_PRIVATE_KEY env var", Self::ConfigFile => "config file", + Self::Keystore => "encrypted keystore", Self::None => "not configured", } } From fe911b0dbfd9b65bcbfa6d1c574579e5fd3372ef Mon Sep 17 00:00:00 2001 From: smypmsa Date: Wed, 25 Feb 2026 11:37:21 +0000 Subject: [PATCH 3/4] fix: preserve actual key source label in setup wizard Keep the source from resolve_key() instead of re-deriving it from keystore_exists() heuristic. Correctly shows "POLYMARKET_PRIVATE_KEY env var" when the key came from the env var. Co-Authored-By: Claude Opus 4.6 --- src/commands/setup.rs | 44 +++++++++++++++++++++---------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/src/commands/setup.rs b/src/commands/setup.rs index 75eb751..cac9e21 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -85,32 +85,30 @@ pub fn execute() -> Result<()> { step_header(1, total, "Wallet"); let address = if config::config_exists() || config::keystore_exists() { - // Try plaintext config first, then encrypted keystore - let existing_addr = { - let (key, _) = config::resolve_key(None); - key.as_deref() + // Try plaintext config / env var / flag first, then encrypted keystore + let (existing_addr, source) = { + let (key, src) = config::resolve_key(None); + let addr = key + .as_deref() .and_then(|k| LocalSigner::from_str(k).ok()) - .map(|s| s.address()) - } - .or_else(|| { - if config::keystore_exists() { - crate::password::prompt_password_with_retries(|pw| { - config::load_key_encrypted(pw) - }) - .ok() - .and_then(|k| LocalSigner::from_str(&k).ok()) - .map(|s| s.address()) - } else { - None - } - }); + .map(|s| s.address()); + (addr, src) + }; + let (existing_addr, source) = if existing_addr.is_some() { + (existing_addr, source) + } else if config::keystore_exists() { + let addr = crate::password::prompt_password_with_retries(|pw| { + config::load_key_encrypted(pw) + }) + .ok() + .and_then(|k| LocalSigner::from_str(&k).ok()) + .map(|s| s.address()); + (addr, config::KeySource::Keystore) + } else { + (None, source) + }; if let Some(addr) = existing_addr { - let source = if config::keystore_exists() { - config::KeySource::Keystore - } else { - config::KeySource::ConfigFile - }; println!(" ✓ Wallet already configured ({})", source.label()); println!(" Address: {addr}"); println!(); From 2e815587ddd23f7dfaf089c802de0caa207a3301 Mon Sep 17 00:00:00 2001 From: smypmsa Date: Wed, 25 Feb 2026 20:14:41 +0000 Subject: [PATCH 4/4] feat: use SecretString for passwords and private keys Wrap all password and private key strings in secrecy::SecretString to zeroize memory on drop and prevent accidental Debug/Display leaks. Requires explicit .expose_secret() for access, making secret usage auditable across the codebase. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + Cargo.toml | 1 + src/auth.rs | 15 ++++++++------- src/commands/setup.rs | 11 ++++++----- src/commands/wallet.rs | 19 +++++++++++-------- src/config.rs | 28 +++++++++++++++------------- src/password.rs | 17 ++++++++++------- 7 files changed, 52 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a90189..d9cfa97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3052,6 +3052,7 @@ dependencies = [ "rust_decimal", "rust_decimal_macros", "rustyline", + "secrecy", "serde", "serde_json", "tabled", diff --git a/Cargo.toml b/Cargo.toml index 3196ecb..caca1e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ dirs = "6" rustyline = "15" rpassword = "7" rand = "0.8" +secrecy = "0.10" [dev-dependencies] assert_cmd = "2" diff --git a/src/auth.rs b/src/auth.rs index 584a996..e475e7c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -6,6 +6,7 @@ use polymarket_client_sdk::auth::state::Authenticated; use polymarket_client_sdk::auth::{LocalSigner, Normal, Signer as _}; use polymarket_client_sdk::clob::types::SignatureType; use polymarket_client_sdk::{POLYGON, clob}; +use secrecy::{ExposeSecret, SecretString}; use crate::config; @@ -20,16 +21,16 @@ fn parse_signature_type(s: &str) -> SignatureType { } /// Resolve the private key hex string, prompting for password if needed. -pub(crate) fn resolve_key_string(private_key: Option<&str>) -> Result { +pub(crate) fn resolve_key_string(private_key: Option<&str>) -> Result { // 1. CLI flag (highest priority — never overridden by migration) if let Some(key) = private_key { - return Ok(key.to_string()); + return Ok(SecretString::from(key.to_string())); } // 2. Env var if let Ok(key) = std::env::var(config::ENV_VAR) && !key.is_empty() { - return Ok(key); + return Ok(SecretString::from(key)); } // Auto-migrate plaintext config to encrypted keystore @@ -38,13 +39,13 @@ pub(crate) fn resolve_key_string(private_key: Option<&str>) -> Result { let password = crate::password::prompt_new_password()?; config::migrate_to_encrypted(&password)?; eprintln!("Wallet key encrypted successfully."); - return config::load_key_encrypted(&password); + return config::load_key_encrypted(password.expose_secret()); } // 3. Old config (plaintext — for backward compat) if let Some(cfg) = config::load_config() && !cfg.private_key.is_empty() { - return Ok(cfg.private_key); + return Ok(SecretString::from(cfg.private_key)); } // 4. Encrypted keystore with retry if config::keystore_exists() { @@ -59,7 +60,7 @@ pub fn resolve_signer( private_key: Option<&str>, ) -> Result { let key = resolve_key_string(private_key)?; - LocalSigner::from_str(&key) + LocalSigner::from_str(key.expose_secret()) .context("Invalid private key") .map(|s| s.with_chain_id(Some(POLYGON))) } @@ -97,7 +98,7 @@ pub async fn create_provider( private_key: Option<&str>, ) -> Result { let key = resolve_key_string(private_key)?; - let signer = LocalSigner::from_str(&key) + let signer = LocalSigner::from_str(key.expose_secret()) .context("Invalid private key")? .with_chain_id(Some(POLYGON)); ProviderBuilder::new() diff --git a/src/commands/setup.rs b/src/commands/setup.rs index cac9e21..c69c104 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -6,6 +6,7 @@ use anyhow::{Context, Result}; use polymarket_client_sdk::auth::{LocalSigner, Signer as _}; use polymarket_client_sdk::types::Address; use polymarket_client_sdk::{POLYGON, derive_proxy_wallet}; +use secrecy::{ExposeSecret, SecretString}; use super::wallet::normalize_key; use crate::config; @@ -89,8 +90,8 @@ pub fn execute() -> Result<()> { let (existing_addr, source) = { let (key, src) = config::resolve_key(None); let addr = key - .as_deref() - .and_then(|k| LocalSigner::from_str(k).ok()) + .as_ref() + .and_then(|k| LocalSigner::from_str(k.expose_secret()).ok()) .map(|s| s.address()); (addr, src) }; @@ -101,7 +102,7 @@ pub fn execute() -> Result<()> { config::load_key_encrypted(pw) }) .ok() - .and_then(|k| LocalSigner::from_str(&k).ok()) + .and_then(|k| LocalSigner::from_str(k.expose_secret()).ok()) .map(|s| s.address()); (addr, config::KeySource::Keystore) } else { @@ -138,7 +139,7 @@ fn setup_wallet() -> Result
{ let signer = LocalSigner::from_str(&normalized) .context("Invalid private key")? .with_chain_id(Some(POLYGON)); - (signer.address(), normalized) + (signer.address(), SecretString::from(normalized)) } else { let signer = LocalSigner::random().with_chain_id(Some(POLYGON)); let address = signer.address(); @@ -148,7 +149,7 @@ fn setup_wallet() -> Result
{ for b in &bytes { write!(hex, "{b:02x}").unwrap(); } - (address, hex) + (address, SecretString::from(hex)) }; let password = crate::password::prompt_new_password()?; diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index a1baf79..bdb46e5 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -6,6 +6,7 @@ use clap::{Args, Subcommand}; use polymarket_client_sdk::auth::LocalSigner; use polymarket_client_sdk::auth::Signer as _; use polymarket_client_sdk::{POLYGON, derive_proxy_wallet}; +use secrecy::{ExposeSecret, SecretString}; use crate::config; use crate::output::OutputFormat; @@ -97,11 +98,12 @@ fn cmd_create(output: &OutputFormat, force: bool, signature_type: &str) -> Resul let signer = LocalSigner::random().with_chain_id(Some(POLYGON)); let address = signer.address(); let bytes = signer.credential().to_bytes(); - let mut key_hex = String::with_capacity(2 + bytes.len() * 2); - key_hex.push_str("0x"); + let mut hex = String::with_capacity(2 + bytes.len() * 2); + hex.push_str("0x"); for b in &bytes { - write!(key_hex, "{b:02x}").unwrap(); + write!(hex, "{b:02x}").unwrap(); } + let key_hex = SecretString::from(hex); let password = crate::password::prompt_new_password()?; config::save_key_encrypted(&key_hex, &password)?; @@ -145,6 +147,7 @@ fn cmd_import(key: &str, output: &OutputFormat, force: bool, signature_type: &st .context("Invalid private key")? .with_chain_id(Some(POLYGON)); let address = signer.address(); + let normalized = SecretString::from(normalized); let password = crate::password::prompt_new_password()?; config::save_key_encrypted(&normalized, &password)?; @@ -180,7 +183,7 @@ fn cmd_import(key: &str, output: &OutputFormat, force: bool, signature_type: &st fn cmd_address(output: &OutputFormat, private_key_flag: Option<&str>) -> Result<()> { let key = crate::auth::resolve_key_string(private_key_flag)?; - let signer = LocalSigner::from_str(&key).context("Invalid private key")?; + let signer = LocalSigner::from_str(key.expose_secret()).context("Invalid private key")?; let address = signer.address(); match output { @@ -197,7 +200,7 @@ fn cmd_address(output: &OutputFormat, private_key_flag: Option<&str>) -> Result< fn cmd_show(output: &OutputFormat, private_key_flag: Option<&str>) -> Result<()> { let (key_result, source) = { let (old_key, old_source) = config::resolve_key(private_key_flag); - if old_key.as_ref().is_some_and(|k| !k.is_empty()) { + if old_key.as_ref().is_some_and(|k| !k.expose_secret().is_empty()) { (Ok(old_key.unwrap()), old_source) } else if config::keystore_exists() { let result = crate::password::prompt_password_with_retries(|pw| { @@ -209,7 +212,7 @@ fn cmd_show(output: &OutputFormat, private_key_flag: Option<&str>) -> Result<()> } }; - let signer = key_result.ok().and_then(|k| LocalSigner::from_str(&k).ok()); + let signer = key_result.ok().and_then(|k| LocalSigner::from_str(k.expose_secret()).ok()); let address = signer.as_ref().map(|s| s.address().to_string()); let proxy_addr = signer .as_ref() @@ -260,10 +263,10 @@ fn cmd_export(output: &OutputFormat) -> Result<()> { match output { OutputFormat::Json => { - println!("{}", serde_json::json!({"private_key": key})); + println!("{}", serde_json::json!({"private_key": key.expose_secret()})); } OutputFormat::Table => { - println!("{key}"); + println!("{}", key.expose_secret()); } } Ok(()) diff --git a/src/config.rs b/src/config.rs index dbe4cbb..72185e4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,7 @@ use std::fs; use std::path::PathBuf; use anyhow::{Context, Result}; +use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; pub const ENV_VAR: &str = "POLYMARKET_PRIVATE_KEY"; @@ -102,7 +103,7 @@ pub fn needs_migration() -> bool { } /// Encrypt a private key and save as keystore.json. -pub fn save_key_encrypted(key_hex: &str, password: &str) -> Result<()> { +pub fn save_key_encrypted(key_hex: &SecretString, password: &SecretString) -> Result<()> { use std::str::FromStr; let dir = config_dir()?; @@ -114,13 +115,13 @@ pub fn save_key_encrypted(key_hex: &str, password: &str) -> Result<()> { fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?; } - let signer = alloy::signers::local::LocalSigner::from_str(key_hex) + let signer = alloy::signers::local::LocalSigner::from_str(key_hex.expose_secret()) .map_err(|e| anyhow::anyhow!("Invalid private key: {e}"))?; let key_bytes = signer.credential().to_bytes(); let mut rng = rand::thread_rng(); alloy::signers::local::LocalSigner::encrypt_keystore( - &dir, &mut rng, key_bytes, password, Some("keystore"), + &dir, &mut rng, key_bytes, password.expose_secret(), Some("keystore"), ) .map_err(|e| anyhow::anyhow!("Failed to encrypt keystore: {e}"))?; @@ -143,7 +144,7 @@ pub fn save_key_encrypted(key_hex: &str, password: &str) -> Result<()> { } /// Decrypt keystore.json and return the private key as 0x-prefixed hex. -pub fn load_key_encrypted(password: &str) -> Result { +pub fn load_key_encrypted(password: &str) -> Result { use std::fmt::Write as _; let path = keystore_path()?; @@ -163,11 +164,11 @@ pub fn load_key_encrypted(password: &str) -> Result { for b in &bytes { write!(hex, "{b:02x}").unwrap(); } - Ok(hex) + Ok(SecretString::from(hex)) } /// Migrate old plaintext config to encrypted keystore. -pub fn migrate_to_encrypted(password: &str) -> Result<()> { +pub fn migrate_to_encrypted(password: &SecretString) -> Result<()> { let config = load_config() .ok_or_else(|| anyhow::anyhow!("No config file found to migrate"))?; @@ -176,7 +177,7 @@ pub fn migrate_to_encrypted(password: &str) -> Result<()> { } // Encrypt the key - save_key_encrypted(&config.private_key, password)?; + save_key_encrypted(&SecretString::from(config.private_key), password)?; // Rewrite config.json without private_key save_wallet_settings(config.chain_id, &config.signature_type)?; @@ -218,17 +219,17 @@ pub fn save_wallet_settings(chain_id: u64, signature_type: &str) -> Result<()> { } /// Priority: CLI flag > env var > config file. -pub fn resolve_key(cli_flag: Option<&str>) -> (Option, KeySource) { +pub fn resolve_key(cli_flag: Option<&str>) -> (Option, KeySource) { if let Some(key) = cli_flag { - return (Some(key.to_string()), KeySource::Flag); + return (Some(SecretString::from(key.to_string())), KeySource::Flag); } if let Ok(key) = std::env::var(ENV_VAR) && !key.is_empty() { - return (Some(key), KeySource::EnvVar); + return (Some(SecretString::from(key)), KeySource::EnvVar); } if let Some(config) = load_config() { - return (Some(config.private_key), KeySource::ConfigFile); + return (Some(SecretString::from(config.private_key)), KeySource::ConfigFile); } (None, KeySource::None) } @@ -236,6 +237,7 @@ pub fn resolve_key(cli_flag: Option<&str>) -> (Option, KeySource) { #[cfg(test)] mod tests { use super::*; + use secrecy::ExposeSecret; use std::sync::Mutex; // Mutex to serialize env var tests (set_var is not thread-safe) @@ -254,7 +256,7 @@ mod tests { let _lock = ENV_LOCK.lock().unwrap(); unsafe { set(ENV_VAR, "env_key") }; let (key, source) = resolve_key(Some("flag_key")); - assert_eq!(key.unwrap(), "flag_key"); + assert_eq!(key.unwrap().expose_secret(), "flag_key"); assert!(matches!(source, KeySource::Flag)); unsafe { unset(ENV_VAR) }; } @@ -264,7 +266,7 @@ mod tests { let _lock = ENV_LOCK.lock().unwrap(); unsafe { set(ENV_VAR, "env_key_value") }; let (key, source) = resolve_key(None); - assert_eq!(key.unwrap(), "env_key_value"); + assert_eq!(key.unwrap().expose_secret(), "env_key_value"); assert!(matches!(source, KeySource::EnvVar)); unsafe { unset(ENV_VAR) }; } diff --git a/src/password.rs b/src/password.rs index 76bc8bf..2bf4640 100644 --- a/src/password.rs +++ b/src/password.rs @@ -1,25 +1,28 @@ use anyhow::{Result, bail}; +use secrecy::{ExposeSecret, SecretString}; const PASSWORD_ENV_VAR: &str = "POLYMARKET_PASSWORD"; /// Prompt for password, or read from POLYMARKET_PASSWORD env var. -pub fn prompt_password(prompt_msg: &str) -> Result { +pub fn prompt_password(prompt_msg: &str) -> Result { if let Ok(pw) = std::env::var(PASSWORD_ENV_VAR) && !pw.is_empty() { - return Ok(pw); + return Ok(SecretString::from(pw)); } - rpassword::prompt_password(prompt_msg).map_err(Into::into) + rpassword::prompt_password(prompt_msg) + .map(SecretString::from) + .map_err(Into::into) } /// Prompt for password with confirmation (for create/import). -pub fn prompt_new_password() -> Result { +pub fn prompt_new_password() -> Result { let pw = prompt_password("Enter password to encrypt wallet: ")?; - if pw.is_empty() { + if pw.expose_secret().is_empty() { bail!("Password cannot be empty"); } let confirm = prompt_password("Confirm password: ")?; - if pw != confirm { + if pw.expose_secret() != confirm.expose_secret() { bail!("Passwords do not match"); } Ok(pw) @@ -33,7 +36,7 @@ where { for attempt in 1..=3 { let pw = prompt_password("Enter wallet password: ")?; - match try_fn(&pw) { + match try_fn(pw.expose_secret()) { Ok(val) => return Ok(val), Err(e) => { if attempt < 3 {