Skip to content

Commit 9571449

Browse files
authored
Cache secure-store public key in identity files. (#2533)
1 parent 313fbee commit 9571449

10 files changed

Lines changed: 352 additions & 33 deletions

File tree

cmd/crates/soroban-test/tests/it/integration/secure_store.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,21 @@ async fn secure_store_key_management() {
3535
.stdout_as_str();
3636
assert!(secure_store_address.starts_with('G'));
3737

38+
// validate that the public key is cached on disk (so `keys address` and
39+
// tx-signing hint derivation can skip the keychain on subsequent calls).
40+
let identity_path = sandbox
41+
.config_dir()
42+
.join("identity")
43+
.join(format!("{secure_key_name}.toml"));
44+
let identity_toml = std::fs::read_to_string(&identity_path).unwrap_or_else(|err| {
45+
panic!("expected identity file at {identity_path:?}: {err}");
46+
});
47+
assert!(
48+
identity_toml.contains(&format!("public_key = \"{secure_store_address}\"")),
49+
"expected public_key to be cached on disk after `keys generate --secure-store`, \
50+
but identity file was:\n{identity_toml}"
51+
);
52+
3853
// use the secure store key to fund a new account
3954
let new_key_name = "new";
4055
sandbox

cmd/soroban-cli/src/commands/keys/add.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,13 @@ impl Cmd {
109109

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

112-
let secret =
113-
secure_store::save_secret(print, &self.name, &seed_phrase, self.overwrite)?;
114-
Ok(secret.parse()?)
112+
Ok(secure_store::save_secret(
113+
print,
114+
&self.name,
115+
&seed_phrase,
116+
None,
117+
self.overwrite,
118+
)?)
115119
} else {
116120
let prompt = "Type a secret key or 12/24 word seed phrase:";
117121
let secret_key = read_password(print, prompt)?;

cmd/soroban-cli/src/commands/keys/generate.rs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,13 @@ impl Cmd {
116116
fn secret(&self, print: &Print) -> Result<Secret, Error> {
117117
let seed_phrase = self.seed_phrase()?;
118118
if self.secure_store {
119-
let secret =
120-
secure_store::save_secret(print, &self.name, &seed_phrase, self.overwrite)?;
121-
Ok(secret.parse()?)
119+
Ok(secure_store::save_secret(
120+
print,
121+
&self.name,
122+
&seed_phrase,
123+
self.hd_path,
124+
self.overwrite,
125+
)?)
122126
} else if self.as_secret {
123127
let secret: Secret = seed_phrase.into();
124128
Ok(secret.private_key(self.hd_path)?.into())
@@ -202,6 +206,34 @@ mod tests {
202206
assert!(matches!(identity, Key::Secret(Secret::SecureStore { .. })));
203207
}
204208

209+
#[cfg(feature = "additional-libs")]
210+
#[tokio::test]
211+
async fn test_generate_secure_store_caches_public_key_on_disk() {
212+
use keyring::{mock, set_default_credential_builder};
213+
set_default_credential_builder(mock::default_credential_builder());
214+
let (test_locator, mut cmd) = set_up_test();
215+
cmd.secure_store = true;
216+
let global_args = global_args();
217+
218+
cmd.run(&global_args).await.unwrap();
219+
220+
let identity = test_locator.read_identity("test_name").unwrap();
221+
match identity {
222+
Key::Secret(Secret::SecureStore {
223+
public_key,
224+
hd_path,
225+
..
226+
}) => {
227+
assert!(
228+
public_key.is_some(),
229+
"public_key should be cached on disk after `keys generate --secure-store`"
230+
);
231+
assert_eq!(hd_path, None);
232+
}
233+
other => panic!("expected SecureStore variant, got {other:?}"),
234+
}
235+
}
236+
205237
#[cfg(not(feature = "additional-libs"))]
206238
#[tokio::test]
207239
async fn test_storing_in_secure_store_returns_error_when_additional_libs_not_enabled() {

cmd/soroban-cli/src/commands/message/sign.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ impl Cmd {
7777

7878
// Get the signer
7979
let key_or_name = &self.sign_with_key;
80-
let secret = self.locator.get_secret_key(key_or_name)?;
80+
let secret = self
81+
.locator
82+
.get_secret_key_with_hd_path(key_or_name, self.hd_path)?;
8183
let signer = secret.signer(self.hd_path, print.clone()).await?;
8284
let public_key = signer.get_public_key()?;
8385

cmd/soroban-cli/src/config/address.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,9 @@ impl UnresolvedMuxedAccount {
116116
) -> Result<xdr::MuxedAccount, Error> {
117117
match self {
118118
UnresolvedMuxedAccount::Resolved(muxed_account) => Ok(muxed_account.clone()),
119-
UnresolvedMuxedAccount::AliasOrSecret(alias_or_secret) => {
120-
Ok(locator.read_key(alias_or_secret)?.muxed_account(hd_path)?)
121-
}
119+
UnresolvedMuxedAccount::AliasOrSecret(alias_or_secret) => Ok(locator
120+
.read_key_with_secure_store_cache(alias_or_secret, hd_path)?
121+
.muxed_account(hd_path)?),
122122
UnresolvedMuxedAccount::Ledger(_) => Err(Error::LedgerNotSupported),
123123
}
124124
}

cmd/soroban-cli/src/config/locator.rs

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,38 @@ impl Args {
299299
.or_else(|_| self.read_identity(key_or_name))
300300
}
301301

302+
/// Like [`Args::read_key`], but for a `SecureStore` identity loaded from disk
303+
/// that lacks a cached public key, derive one via the keychain (one prompt)
304+
/// and persist it back so subsequent reads avoid the keychain.
305+
pub fn read_key_with_secure_store_cache(
306+
&self,
307+
key_or_name: &str,
308+
hd_path: Option<usize>,
309+
) -> Result<Key, Error> {
310+
if let Ok(literal) = key_or_name.parse::<Key>() {
311+
return Ok(literal);
312+
}
313+
let key = self.read_identity(key_or_name)?;
314+
if let Key::Secret(Secret::SecureStore {
315+
entry_name,
316+
public_key: None,
317+
..
318+
}) = &key
319+
{
320+
let pk = secure_store::get_public_key(entry_name, hd_path)?;
321+
let migrated = Key::Secret(Secret::SecureStore {
322+
entry_name: entry_name.clone(),
323+
public_key: Some(pk.to_string()),
324+
hd_path,
325+
});
326+
// Best-effort write-back: if persistence fails we still return the
327+
// freshly-derived value so the current call succeeds.
328+
let _ = self.write_key(key_or_name, &migrated);
329+
return Ok(migrated);
330+
}
331+
Ok(key)
332+
}
333+
302334
pub fn get_secret_key(&self, key_or_name: &str) -> Result<Secret, Error> {
303335
let key = self.read_key(key_or_name).map_err(|e| match e {
304336
Error::InvalidName(_) | Error::ConfigMissing(_, _) => Error::InvalidSigningKey,
@@ -310,6 +342,27 @@ impl Args {
310342
}
311343
}
312344

345+
/// Like [`Args::get_secret_key`], but if the secret is a `SecureStore`
346+
/// identity loaded from disk without a cached public key, derive it for the
347+
/// given `hd_path` and persist the cache. Use from signing paths so the
348+
/// returned `Secret` already carries the data signing needs for the hint.
349+
pub fn get_secret_key_with_hd_path(
350+
&self,
351+
key_or_name: &str,
352+
hd_path: Option<usize>,
353+
) -> Result<Secret, Error> {
354+
let key = self
355+
.read_key_with_secure_store_cache(key_or_name, hd_path)
356+
.map_err(|e| match e {
357+
Error::InvalidName(_) | Error::ConfigMissing(_, _) => Error::InvalidSigningKey,
358+
other => other,
359+
})?;
360+
match key {
361+
Key::Secret(s) => Ok(s),
362+
_ => Err(Error::InvalidSigningKey),
363+
}
364+
}
365+
313366
pub fn get_public_key(
314367
&self,
315368
key_or_name: &str,
@@ -334,7 +387,7 @@ impl Args {
334387
let print = Print::new(global_args.quiet);
335388
let identity = self.read_identity(name)?;
336389

337-
if let Key::Secret(Secret::SecureStore { entry_name }) = identity {
390+
if let Key::Secret(Secret::SecureStore { entry_name, .. }) = identity {
338391
secure_store::delete_secret(&print, &entry_name)?;
339392
}
340393

@@ -1209,4 +1262,79 @@ mod tests {
12091262
print_deprecation_warning(&local_dir);
12101263
});
12111264
}
1265+
1266+
mod secure_store_cache {
1267+
use super::super::*;
1268+
1269+
const TEST_PUBLIC_KEY: &str = "GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ";
1270+
const TEST_SECRET_KEY: &str = "SBF5HLRREHMS36XZNTUSKZ6FTXDZGNXOHF4EXKUL5UCWZLPBX3NGJ4BH";
1271+
1272+
fn locator_with_tempdir() -> (tempfile::TempDir, Args) {
1273+
let dir = tempfile::tempdir().unwrap();
1274+
let args = Args {
1275+
config_dir: Some(dir.path().to_path_buf()),
1276+
};
1277+
(dir, args)
1278+
}
1279+
1280+
// The legacy-file -> derive-via-keychain -> write-back path is
1281+
// exercised end-to-end by the soroban-test integration test
1282+
// `secure_store_key_management`. The keyring crate's mock builder
1283+
// assigns each `Entry` instance its own in-memory credential
1284+
// (CredentialPersistence::EntryOnly), which makes the read-after-write
1285+
// round trip impossible to simulate in pure unit tests.
1286+
1287+
#[test]
1288+
fn passes_through_already_cached_identity_without_keychain_access() {
1289+
let (_dir, locator) = locator_with_tempdir();
1290+
1291+
// Entry name points to a non-existent keychain entry, so any
1292+
// keychain access would fail the test.
1293+
let already = Secret::SecureStore {
1294+
entry_name: "secure_store:org.stellar.cli-no-such-entry".to_string(),
1295+
public_key: Some(TEST_PUBLIC_KEY.to_string()),
1296+
hd_path: None,
1297+
};
1298+
locator.write_identity("already", &already).unwrap();
1299+
1300+
let key = locator
1301+
.read_key_with_secure_store_cache("already", None)
1302+
.unwrap();
1303+
match key {
1304+
Key::Secret(Secret::SecureStore {
1305+
public_key: Some(pk),
1306+
..
1307+
}) => assert_eq!(pk, TEST_PUBLIC_KEY),
1308+
other => panic!("expected SecureStore, got {other:?}"),
1309+
}
1310+
}
1311+
1312+
#[test]
1313+
fn passes_through_non_secure_store_identity() {
1314+
let (_dir, locator) = locator_with_tempdir();
1315+
1316+
let secret = Secret::SecretKey {
1317+
secret_key: TEST_SECRET_KEY.to_string(),
1318+
};
1319+
locator.write_identity("plain", &secret).unwrap();
1320+
1321+
let key = locator
1322+
.read_key_with_secure_store_cache("plain", None)
1323+
.unwrap();
1324+
assert!(matches!(
1325+
key,
1326+
Key::Secret(Secret::SecretKey { ref secret_key }) if secret_key == TEST_SECRET_KEY
1327+
));
1328+
}
1329+
1330+
#[test]
1331+
fn returns_literal_public_key_without_disk_lookup() {
1332+
let (_dir, locator) = locator_with_tempdir();
1333+
1334+
let key = locator
1335+
.read_key_with_secure_store_cache(TEST_PUBLIC_KEY, None)
1336+
.unwrap();
1337+
assert!(matches!(key, Key::PublicKey(_)));
1338+
}
1339+
}
12121340
}

0 commit comments

Comments
 (0)