Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
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 <noreply@anthropic.com>
  • Loading branch information
smypmsa and claude committed Feb 25, 2026
commit 2e815587ddd23f7dfaf089c802de0caa207a3301
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dirs = "6"
rustyline = "15"
rpassword = "7"
rand = "0.8"
secrecy = "0.10"

[dev-dependencies]
assert_cmd = "2"
Expand Down
15 changes: 8 additions & 7 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<String> {
pub(crate) fn resolve_key_string(private_key: Option<&str>) -> Result<SecretString> {
// 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
Expand All @@ -38,13 +39,13 @@ pub(crate) fn resolve_key_string(private_key: Option<&str>) -> Result<String> {
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() {
Expand All @@ -59,7 +60,7 @@ pub fn resolve_signer(
private_key: Option<&str>,
) -> Result<impl polymarket_client_sdk::auth::Signer> {
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)))
}
Expand Down Expand Up @@ -97,7 +98,7 @@ pub async fn create_provider(
private_key: Option<&str>,
) -> Result<impl alloy::providers::Provider + Clone> {
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()
Expand Down
11 changes: 6 additions & 5 deletions src/commands/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
};
Expand All @@ -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 {
Expand Down Expand Up @@ -138,7 +139,7 @@ fn setup_wallet() -> Result<Address> {
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();
Expand All @@ -148,7 +149,7 @@ fn setup_wallet() -> Result<Address> {
for b in &bytes {
write!(hex, "{b:02x}").unwrap();
}
(address, hex)
(address, SecretString::from(hex))
};

let password = crate::password::prompt_new_password()?;
Expand Down
19 changes: 11 additions & 8 deletions src/commands/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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 {
Expand All @@ -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| {
Expand All @@ -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()
Expand Down Expand Up @@ -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(())
Expand Down
28 changes: 15 additions & 13 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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()?;
Expand All @@ -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}"))?;

Expand All @@ -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<String> {
pub fn load_key_encrypted(password: &str) -> Result<SecretString> {
use std::fmt::Write as _;

let path = keystore_path()?;
Expand All @@ -163,11 +164,11 @@ pub fn load_key_encrypted(password: &str) -> Result<String> {
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"))?;

Expand All @@ -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)?;
Expand Down Expand Up @@ -218,24 +219,25 @@ 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<String>, KeySource) {
pub fn resolve_key(cli_flag: Option<&str>) -> (Option<SecretString>, 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)
}

#[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)
Expand All @@ -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) };
}
Expand All @@ -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) };
}
Expand Down
17 changes: 10 additions & 7 deletions src/password.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
pub fn prompt_password(prompt_msg: &str) -> Result<SecretString> {
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<String> {
pub fn prompt_new_password() -> Result<SecretString> {
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)
Expand All @@ -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 {
Expand Down