Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
d7b9c18
Store StellarEntry on SecureStoryEntry and reuse it
elizabethengelman Dec 2, 2025
6e56981
Create a SecureStoreEntry constructor
elizabethengelman Dec 2, 2025
beed676
Cache the seedphrase in StellarEntry
elizabethengelman Dec 2, 2025
019ae15
Cache public key in StellarEntry
elizabethengelman Dec 3, 2025
bf61b15
Pull SecureStoreEntry into its own mod
elizabethengelman Dec 4, 2025
ff5bfb8
And remove the need for separate secure_store mod
elizabethengelman Dec 4, 2025
3cb4abf
Cargo fmt
elizabethengelman Dec 4, 2025
426062f
Fix typo
elizabethengelman Dec 4, 2025
5815575
WIP - cache key lookup in locator
elizabethengelman Dec 5, 2025
f75b923
Use get_or_init the cached stellar entry
elizabethengelman Dec 8, 2025
0af0a3f
Cleanup
elizabethengelman Dec 8, 2025
a7934f8
Remove unused name field on SecureStoreEntry
elizabethengelman Dec 8, 2025
af9d430
Remove unnecessary cached public key in StellarEntry
elizabethengelman Dec 8, 2025
df86683
Cleanup
elizabethengelman Dec 8, 2025
c174c24
Cleanup unwraps
elizabethengelman Dec 8, 2025
a36c403
Fix to compile without additional-libs feature
elizabethengelman Dec 9, 2025
968e5db
Merge branch 'main' into fix/secure-store-promting
elizabethengelman Dec 9, 2025
fb770cf
Fixup after merge
elizabethengelman Dec 9, 2025
4c30730
Clippy
elizabethengelman Dec 9, 2025
9a38824
Update key test to account for not implementing PartialEq
elizabethengelman Dec 10, 2025
db79631
Fmt
elizabethengelman Dec 10, 2025
379d408
Clippy for ubuntu builds
elizabethengelman Dec 10, 2025
1b928c6
Clear the cached seed when a key is deleted
elizabethengelman Dec 10, 2025
569432b
Fix integration test
elizabethengelman Dec 10, 2025
bd4b856
Cargo check fixes
elizabethengelman Dec 10, 2025
9e13756
Cleanup
elizabethengelman Dec 10, 2025
97c83a5
Handle an unwrap
elizabethengelman Dec 10, 2025
f57be37
Embed strkey CLI command for encoding and decoding address strings (#…
leighmcculloch Dec 11, 2025
fec719c
Merge branch 'main' into fix/secure-store-promting
elizabethengelman Dec 12, 2025
aa40550
Merge branch 'main' into fix/secure-store-promting
elizabethengelman Dec 12, 2025
dc3f5bb
Cargo fmt
elizabethengelman Dec 15, 2025
383c9ea
Merge branch 'main' into fix/secure-store-promting
elizabethengelman Dec 17, 2025
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
2 changes: 2 additions & 0 deletions cmd/crates/soroban-test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ use std::{
ffi::OsString,
fmt::Display,
path::{Path, PathBuf},
sync::OnceLock,
};

use assert_cmd::{assert::Assert, Command};
Expand Down Expand Up @@ -277,6 +278,7 @@ impl TestEnv {
locator: config::locator::Args {
global: false,
config_dir,
cached_keys: OnceLock::new(),
},
sign_with: config::sign_with::Args {
sign_with_key: None,
Expand Down
7 changes: 4 additions & 3 deletions cmd/crates/soroban-test/tests/it/integration/hello_world.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use super::util::{deploy_hello, extend};
use crate::integration::util::extend_contract;
use soroban_cli::{
commands::{
contract::{self, fetch},
Expand All @@ -6,9 +8,7 @@ use soroban_cli::{
config::{locator, secret},
};
use soroban_test::{AssertExt, TestEnv, LOCAL_NETWORK_PASSPHRASE};

use super::util::{deploy_hello, extend};
use crate::integration::util::extend_contract;
use std::sync::OnceLock;

#[allow(clippy::too_many_lines)]
#[tokio::test]
Expand Down Expand Up @@ -101,6 +101,7 @@ async fn invoke_contract() {
let config_locator = locator::Args {
global: false,
config_dir: Some(dir.to_path_buf()),
cached_keys: OnceLock::new(),
};

config_locator
Expand Down
6 changes: 3 additions & 3 deletions cmd/soroban-cli/src/commands/keys/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::{
secret::{self, Secret},
},
print::Print,
signer::secure_store,
signer::secure_store_entry::{self, SecureStoreEntry},
};

#[derive(thiserror::Error, Debug)]
Expand All @@ -23,7 +23,7 @@ pub enum Error {
Config(#[from] locator::Error),

#[error(transparent)]
SecureStore(#[from] secure_store::Error),
SecureStoreEntry(#[from] secure_store_entry::Error),

#[error(transparent)]
SeedPhrase(#[from] sep5::error::Error),
Expand Down Expand Up @@ -96,7 +96,7 @@ impl Cmd {

let seed_phrase: SeedPhrase = secret_key.parse()?;

let secret = secure_store::save_secret(print, &self.name, &seed_phrase)?;
let secret = SecureStoreEntry::create_and_save(&self.name, &seed_phrase, print)?;
Ok(secret.parse()?)
} else {
let prompt = "Type a secret key or 12/24 word seed phrase:";
Expand Down
14 changes: 11 additions & 3 deletions cmd/soroban-cli/src/commands/keys/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ use super::super::config::{
secret::{self, Secret},
};

use crate::{commands::global, config::address::KeyName, print::Print, signer::secure_store};
use crate::{
commands::global,
config::address::KeyName,
print::Print,
signer::secure_store_entry::{self, SecureStoreEntry},
};

#[derive(thiserror::Error, Debug)]
pub enum Error {
Expand All @@ -22,7 +27,7 @@ pub enum Error {
IdentityAlreadyExists(String),

#[error(transparent)]
SecureStore(#[from] secure_store::Error),
SecureStoreEntry(#[from] secure_store_entry::Error),
}

#[derive(Debug, clap::Parser, Clone)]
Expand Down Expand Up @@ -115,7 +120,7 @@ impl Cmd {
fn secret(&self, print: &Print) -> Result<Secret, Error> {
let seed_phrase = self.seed_phrase()?;
if self.secure_store {
let secret = secure_store::save_secret(print, &self.name, &seed_phrase)?;
let secret = SecureStoreEntry::create_and_save(&self.name, &seed_phrase, print)?;
Ok(secret.parse()?)
} else if self.as_secret {
let secret: Secret = seed_phrase.into();
Expand All @@ -132,13 +137,16 @@ impl Cmd {

#[cfg(test)]
mod tests {
use std::sync::OnceLock;

use crate::config::{address::KeyName, key::Key, secret::Secret};

fn set_up_test() -> (super::locator::Args, super::Cmd) {
let temp_dir = tempfile::tempdir().unwrap();
let locator = super::locator::Args {
global: false,
config_dir: Some(temp_dir.path().to_path_buf()),
cached_keys: OnceLock::new(),
};

let cmd = super::Cmd {
Expand Down
67 changes: 62 additions & 5 deletions cmd/soroban-cli/src/config/key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub enum Error {
Parse,
}

#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum Key {
#[serde(rename = "public_key")]
PublicKey(Public),
Expand Down Expand Up @@ -85,7 +85,9 @@ impl From<&stellar_strkey::ed25519::PublicKey> for Key {
}
}

#[derive(Debug, PartialEq, Eq, serde_with::SerializeDisplay, serde_with::DeserializeFromStr)]
#[derive(
Debug, Clone, PartialEq, Eq, serde_with::SerializeDisplay, serde_with::DeserializeFromStr,
)]
pub struct Public(pub stellar_strkey::ed25519::PublicKey);

impl FromStr for Public {
Expand All @@ -111,7 +113,9 @@ impl From<&Public> for stellar_strkey::ed25519::MuxedAccount {
}
}

#[derive(Debug, PartialEq, Eq, serde_with::SerializeDisplay, serde_with::DeserializeFromStr)]
#[derive(
Debug, Clone, PartialEq, Eq, serde_with::SerializeDisplay, serde_with::DeserializeFromStr,
)]
pub struct MuxedAccount(pub stellar_strkey::ed25519::MuxedAccount);

impl FromStr for MuxedAccount {
Expand Down Expand Up @@ -143,13 +147,66 @@ impl TryFrom<Key> for Secret {

#[cfg(test)]
mod test {
use std::sync::Arc;

use super::*;

fn round_trip(key: &Key) {
let serialized = toml::to_string(&key).unwrap();
println!("{serialized}");
let deserialized: Key = toml::from_str(&serialized).unwrap();
assert_eq!(key, &deserialized);

assert_key_equality(key, &deserialized);
}

// using this fn instead of just doing assert_eq!(key, deserialized) because Secret::SecureStore keys contain a StellarEntry which contains a keyring::Entry
// keyring::Entry comes from the keyring crate which does not implement PartialEq
fn assert_key_equality(expected: &Key, actual: &Key) {
match (expected, actual) {
(Key::PublicKey(e), Key::PublicKey(a)) => {
assert_eq!(e, a);
}
(Key::MuxedAccount(e), Key::MuxedAccount(a)) => {
assert_eq!(e, a);
}
(Key::Secret(e), Key::Secret(a)) => match (e, a) {
(
Secret::SecretKey {
secret_key: e_secret_key,
},
Secret::SecretKey {
secret_key: a_secret_key,
},
) => {
assert_eq!(e_secret_key, a_secret_key);
}
(
Secret::SeedPhrase {
seed_phrase: e_seed_phrase,
},
Secret::SeedPhrase {
seed_phrase: a_seed_phrase,
},
) => {
assert_eq!(e_seed_phrase, a_seed_phrase);
}
(Secret::Ledger, Secret::Ledger) => todo!(),
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Placeholder todo!() in test code will panic if Ledger equality check is ever executed. Either implement the equality check for Ledger keys or add a comment explaining why this case doesn't need to be handled in current tests.

Suggested change
(Secret::Ledger, Secret::Ledger) => todo!(),
// The Secret::Ledger variant is not constructed or compared in current tests,

Copilot uses AI. Check for mistakes.
(
Secret::SecureStore {
entry_name: e_entry_name,
cached_entry: e_cached_entry,
},
Secret::SecureStore {
entry_name: a_entry_name,
cached_entry: a_cached_entry,
},
) => {
assert_eq!(e_entry_name, a_entry_name);
assert!(Arc::ptr_eq(e_cached_entry, a_cached_entry));
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect pointer comparison logic in test. After deserialization from TOML, a new Secret::SecureStore instance is created with a new Arc (line 99), so Arc::ptr_eq will always return false. This test assertion doesn't correctly verify that deserialization works properly. Consider checking that both cached_entry fields are empty OnceLocks instead, or remove this assertion since the cache is runtime state that shouldn't persist through serialization.

Suggested change
assert!(Arc::ptr_eq(e_cached_entry, a_cached_entry));
// Do not compare cached_entry pointer identity; cache is runtime-only and not serialized.

Copilot uses AI. Check for mistakes.
}
_ => panic!("keys are not equal {expected:?} != {actual:?}"),
},
_ => panic!("keys are not equal {expected:?} != {actual:?}"),
}
}

#[test]
Expand Down
41 changes: 35 additions & 6 deletions cmd/soroban-cli/src/config/locator.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use directories::UserDirs;
use itertools::Itertools;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use std::{
ffi::OsStr,
fmt::Display,
Expand All @@ -14,7 +16,7 @@ use stellar_strkey::{Contract, DecodeError};
use crate::{
commands::{global, HEADING_GLOBAL},
print::Print,
signer::secure_store,
signer::secure_store_entry::{self, SecureStoreEntry},
utils::find_config_dir,
xdr, Pwd,
};
Expand Down Expand Up @@ -96,7 +98,7 @@ pub enum Error {
#[error("Key cannot {0} cannot overlap with contract alias")]
KeyCannotOverlapWithContractAlias(String),
#[error(transparent)]
SecureStore(#[from] secure_store::Error),
SecureStoreEntry(#[from] secure_store_entry::Error),
#[error("Only private keys and seed phrases are supported for getting private keys {0}")]
SecretKeyOnly(String),
#[error(transparent)]
Expand All @@ -105,6 +107,8 @@ pub enum Error {
ProjectDirsError(),
}

pub type CachedKeys = HashMap<String, Key>;

#[derive(Debug, clap::Args, Default, Clone)]
#[group(skip)]
pub struct Args {
Expand All @@ -116,6 +120,10 @@ pub struct Args {
/// Contains configuration files, aliases, and other persistent settings.
#[arg(long, global = true, help_heading = HEADING_GLOBAL)]
pub config_dir: Option<PathBuf>,

#[clap(skip)]
// This saves us from reading the same key from the file system more than once for one cmd
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing documentation for the cached_keys field. This field serves an important purpose of preventing multiple filesystem reads for the same key during a command execution. Consider adding a doc comment explaining its purpose and usage pattern.

Suggested change
// This saves us from reading the same key from the file system more than once for one cmd
/// Caches loaded keys to avoid reading the same key from the filesystem multiple times during a single command execution.
/// This field is initialized once per command and shared via an `Arc<Mutex<...>>` to ensure thread-safe access.
/// It improves performance by preventing redundant disk I/O when the same key is requested multiple times.

Copilot uses AI. Check for mistakes.
pub cached_keys: OnceLock<Arc<Mutex<CachedKeys>>>,
}

pub enum Location {
Expand Down Expand Up @@ -269,10 +277,30 @@ impl Args {
KeyType::Identity.read_with_global(name, self)
}

// read_key caches the Key after reading it from the config
pub fn read_key(&self, key_or_name: &str) -> Result<Key, Error> {
key_or_name
// check cache for key & return it if its there
if let Some(arc) = self.cached_keys.get() {
let map = arc.lock().unwrap();
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential panic from unwrap() call on mutex lock. If the mutex is poisoned, this will panic instead of returning a proper error. Consider using map_err to convert the poison error into a proper Error type, similar to how it's handled in keyring.rs line 80-81.

Copilot uses AI. Check for mistakes.
if let Some(k) = map.get(key_or_name) {
return Ok(k.clone());
}
}

// if its not in the cache, read it from config
let key = key_or_name
.parse()
.or_else(|_| self.read_identity(key_or_name))
.or_else(|_| self.read_identity(key_or_name))?;

// get or initialize the cached keys
let arc = self
.cached_keys
.get_or_init(|| Arc::new(Mutex::new(HashMap::new())));
let mut map = arc.lock().unwrap();
Copy link

Copilot AI Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential panic from unwrap() call on mutex lock. If the mutex is poisoned, this will panic instead of returning a proper error. Consider using map_err to convert the poison error into a proper Error type, similar to how it's handled in keyring.rs line 80-81.

Suggested change
let mut map = arc.lock().unwrap();
let mut map = arc
.lock()
.map_err(|e| Error::ConfigIo(format!("Failed to lock cached_keys mutex: {e}")))?;

Copilot uses AI. Check for mistakes.
// add the key to cached_keys
map.insert(key_or_name.to_string(), key.clone());

Ok(key)
}

pub fn get_secret_key(&self, key_or_name: &str) -> Result<Secret, Error> {
Expand Down Expand Up @@ -305,8 +333,9 @@ impl Args {
let print = Print::new(global_args.quiet);
let identity = self.read_identity(name)?;

if let Key::Secret(Secret::SecureStore { entry_name }) = identity {
secure_store::delete_secret(&print, &entry_name)?;
if let Key::Secret(Secret::SecureStore { entry_name, .. }) = identity {
let secure_store_entry = SecureStoreEntry::new(entry_name, None)?;
secure_store_entry.delete_secret(&print)?;
}

print.infoln("Removing the key's cli config file");
Expand Down
Loading
Loading