@@ -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