Skip to main content

soroban_cli/config/
secret.rs

1use serde::{Deserialize, Serialize};
2use std::str::FromStr;
3
4use sep5::SeedPhrase;
5use stellar_strkey::ed25519::{PrivateKey, PublicKey};
6
7use crate::{
8    print::Print,
9    signer::{
10        self, ledger::LedgerEntry, secure_store, LocalKey, SecureStoreEntry, Signer, SignerKind,
11    },
12    utils,
13};
14
15use super::key::Key;
16
17#[derive(thiserror::Error, Debug)]
18pub enum Error {
19    #[error(transparent)]
20    Secret(#[from] stellar_strkey::DecodeError),
21    #[error(transparent)]
22    SeedPhrase(#[from] sep5::error::Error),
23    #[error(transparent)]
24    Ed25519(#[from] ed25519_dalek::SignatureError),
25    #[error("cannot parse secret (S) or seed phrase (12 or 24 word)")]
26    InvalidSecretOrSeedPhrase,
27    #[error(transparent)]
28    Signer(#[from] signer::Error),
29    #[error("Ledger does not reveal secret key")]
30    LedgerDoesNotRevealSecretKey,
31    #[error(transparent)]
32    SecureStore(#[from] secure_store::Error),
33    #[error("Secure Store does not reveal secret key")]
34    SecureStoreDoesNotRevealSecretKey,
35    #[error(transparent)]
36    Ledger(#[from] signer::ledger::Error),
37    #[error("--hd-path is fixed at the time a Ledger identity is added; pass `--ledger --hd-path N` to inspect another path on the device")]
38    LedgerHdPathFixed,
39}
40
41#[derive(Debug, clap::Args, Clone)]
42#[group(skip)]
43pub struct Args {
44    /// ⚠️ Deprecated, use `--secure-store`. Enter secret (S) key when prompted
45    #[arg(long)]
46    pub secret_key: bool,
47
48    /// ⚠️ Deprecated, use `--secure-store`. Enter key using 12-24 word seed phrase
49    #[arg(long)]
50    pub seed_phrase: bool,
51
52    /// Save the new key in your OS's credential secure store.
53    ///
54    /// On Mac this uses Keychain, on Windows it is Secure Store Service, and on *nix platforms it uses a combination of the kernel keyutils and DBus-based Secret Service.
55    ///
56    /// This only supports seed phrases for now.
57    #[arg(long)]
58    pub secure_store: bool,
59}
60
61#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
62#[serde(untagged)]
63pub enum Secret {
64    SecretKey {
65        secret_key: String,
66    },
67    SeedPhrase {
68        seed_phrase: String,
69        // Persisted derivation index. Lets `--hd-path` set on `keys generate` /
70        // `keys add` travel with the identity, so later commands derive the
71        // intended account without re-passing the flag. Optional for backwards
72        // compatibility with files written before this field existed.
73        #[serde(default, skip_serializing_if = "Option::is_none")]
74        hd_path: Option<u32>,
75    },
76    // Hardware-wallet identity. The required `hardware` field tags the device
77    // kind (currently only `ledger`) and disambiguates this variant under
78    // `untagged`; future wallets can introduce new `HardwareKind` values
79    // without a new Secret variant. The cached `public_key` lets address and
80    // hint lookups succeed without the device being connected.
81    Ledger {
82        hardware: HardwareKind,
83        public_key: String,
84        #[serde(default, skip_serializing_if = "Option::is_none")]
85        hd_path: Option<u32>,
86    },
87    SecureStore {
88        entry_name: String,
89        // Cached public key derived from the secure-store entry. Lets us answer
90        // address/hint queries without unlocking the keychain. Optional for
91        // backwards compatibility with files written before this field existed.
92        #[serde(default, skip_serializing_if = "Option::is_none")]
93        public_key: Option<String>,
94        #[serde(default, skip_serializing_if = "Option::is_none")]
95        hd_path: Option<u32>,
96    },
97}
98
99#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
100#[serde(rename_all = "lowercase")]
101pub enum HardwareKind {
102    Ledger,
103}
104
105impl FromStr for Secret {
106    type Err = Error;
107
108    fn from_str(s: &str) -> Result<Self, Self::Err> {
109        if PrivateKey::from_string(s).is_ok() {
110            Ok(Secret::SecretKey {
111                secret_key: s.to_string(),
112            })
113        } else if sep5::SeedPhrase::from_str(s).is_ok() {
114            Ok(Secret::SeedPhrase {
115                seed_phrase: s.to_string(),
116                hd_path: None,
117            })
118        } else if s.starts_with(secure_store::ENTRY_PREFIX) {
119            Ok(Secret::SecureStore {
120                entry_name: s.to_string(),
121                public_key: None,
122                hd_path: None,
123            })
124        } else {
125            Err(Error::InvalidSecretOrSeedPhrase)
126        }
127    }
128}
129
130impl From<PrivateKey> for Secret {
131    fn from(value: PrivateKey) -> Self {
132        Secret::SecretKey {
133            secret_key: format!("{value}"),
134        }
135    }
136}
137
138impl From<Secret> for Key {
139    fn from(value: Secret) -> Self {
140        Key::Secret(value)
141    }
142}
143
144impl From<SeedPhrase> for Secret {
145    fn from(value: SeedPhrase) -> Self {
146        Secret::SeedPhrase {
147            seed_phrase: value.seed_phrase.into_phrase(),
148            hd_path: None,
149        }
150    }
151}
152
153impl Secret {
154    pub fn private_key(&self, index: Option<u32>) -> Result<PrivateKey, Error> {
155        Ok(match self {
156            Secret::SecretKey { secret_key } => PrivateKey::from_string(secret_key)?,
157            Secret::SeedPhrase {
158                seed_phrase,
159                hd_path,
160            } => PrivateKey::from_payload(
161                &sep5::SeedPhrase::from_str(seed_phrase)?
162                    .from_path_index(index.or(*hd_path).unwrap_or_default() as usize, None)?
163                    .private()
164                    .0,
165            )?,
166            Secret::Ledger { .. } => return Err(Error::LedgerDoesNotRevealSecretKey),
167            Secret::SecureStore { .. } => {
168                return Err(Error::SecureStoreDoesNotRevealSecretKey);
169            }
170        })
171    }
172
173    pub fn public_key(&self, index: Option<u32>) -> Result<PublicKey, Error> {
174        match self {
175            Secret::SecureStore {
176                entry_name,
177                public_key,
178                hd_path,
179            } => {
180                let effective = index.or(*hd_path);
181                if let Some(cached) = cached_public_key(public_key.as_deref(), *hd_path, effective)
182                {
183                    return Ok(cached);
184                }
185                Ok(secure_store::get_public_key(entry_name, effective)?)
186            }
187            Secret::Ledger { public_key, .. } => {
188                if index.is_some() {
189                    return Err(Error::LedgerHdPathFixed);
190                }
191                Ok(PublicKey::from_string(public_key)?)
192            }
193            _ => {
194                let key = self.key_pair(index)?;
195                Ok(stellar_strkey::ed25519::PublicKey::from_payload(
196                    key.verifying_key().as_bytes(),
197                )?)
198            }
199        }
200    }
201
202    pub fn signer(&self, hd_path: Option<u32>, print: Print) -> Result<Signer, Error> {
203        let kind = match self {
204            Secret::SecretKey { .. } | Secret::SeedPhrase { .. } => {
205                let key = self.key_pair(hd_path)?;
206                SignerKind::Local(LocalKey { key })
207            }
208            Secret::Ledger {
209                hardware: HardwareKind::Ledger,
210                public_key,
211                hd_path: cached_hd_path,
212            } => {
213                if hd_path.is_some() {
214                    return Err(Error::LedgerHdPathFixed);
215                }
216                SignerKind::Ledger(LedgerEntry {
217                    hd_path: cached_hd_path.unwrap_or_default(),
218                    public_key: Some(PublicKey::from_string(public_key)?),
219                })
220            }
221            Secret::SecureStore {
222                entry_name,
223                public_key,
224                hd_path: cached_hd_path,
225            } => {
226                let effective = hd_path.or(*cached_hd_path);
227                let cached_public_key =
228                    cached_public_key(public_key.as_deref(), *cached_hd_path, effective);
229                SignerKind::SecureStore(SecureStoreEntry {
230                    name: entry_name.clone(),
231                    hd_path: effective,
232                    public_key: cached_public_key,
233                })
234            }
235        };
236        Ok(Signer { kind, print })
237    }
238
239    pub fn key_pair(&self, index: Option<u32>) -> Result<ed25519_dalek::SigningKey, Error> {
240        Ok(utils::into_signing_key(&self.private_key(index)?))
241    }
242
243    pub fn from_seed(seed: Option<&str>) -> Result<Self, Error> {
244        Ok(seed_phrase_from_seed(seed)?.into())
245    }
246}
247
248// Returns the cached public key when it can be used, or `None` to signal a
249// cache miss. The cache is best-effort: a malformed cached value is ignored
250// rather than propagated, and `None`/`Some(0)` are treated as the same path
251// since the rest of the codebase uses `unwrap_or_default()` for hd_path.
252fn cached_public_key(
253    cached: Option<&str>,
254    cached_hd_path: Option<u32>,
255    requested_hd_path: Option<u32>,
256) -> Option<PublicKey> {
257    if cached_hd_path.unwrap_or_default() != requested_hd_path.unwrap_or_default() {
258        return None;
259    }
260    PublicKey::from_string(cached?).ok()
261}
262
263pub fn seed_phrase_from_seed(seed: Option<&str>) -> Result<SeedPhrase, Error> {
264    Ok(if let Some(seed) = seed.map(str::as_bytes) {
265        sep5::SeedPhrase::from_entropy(seed)?
266    } else {
267        sep5::SeedPhrase::random(sep5::MnemonicType::Words24)?
268    })
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    const TEST_PUBLIC_KEY: &str = "GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ";
276    const TEST_SECRET_KEY: &str = "SBF5HLRREHMS36XZNTUSKZ6FTXDZGNXOHF4EXKUL5UCWZLPBX3NGJ4BH";
277    const TEST_SEED_PHRASE: &str =
278        "depth decade power loud smile spatial sign movie judge february rate broccoli";
279
280    #[test]
281    fn test_from_str_for_secret_key() {
282        let secret = Secret::from_str(TEST_SECRET_KEY).unwrap();
283        let public_key = secret.public_key(None).unwrap();
284        let private_key = secret.private_key(None).unwrap();
285
286        assert!(matches!(secret, Secret::SecretKey { .. }));
287        assert_eq!(public_key.to_string(), TEST_PUBLIC_KEY);
288        assert_eq!(private_key.to_string(), TEST_SECRET_KEY);
289    }
290
291    #[test]
292    fn test_secret_from_seed_phrase() {
293        let secret = Secret::from_str(TEST_SEED_PHRASE).unwrap();
294        let public_key = secret.public_key(None).unwrap();
295        let private_key = secret.private_key(None).unwrap();
296
297        assert!(matches!(secret, Secret::SeedPhrase { .. }));
298        assert_eq!(public_key.to_string(), TEST_PUBLIC_KEY);
299        assert_eq!(private_key.to_string(), TEST_SECRET_KEY);
300    }
301
302    #[test]
303    fn test_secret_from_secure_store() {
304        //todo: add assertion for getting public key - will need to mock the keychain and add the keypair to the keychain
305        let secret = Secret::from_str("secure_store:org.stellar.cli-alice").unwrap();
306        assert!(matches!(secret, Secret::SecureStore { .. }));
307
308        let private_key_result = secret.private_key(None);
309        assert!(private_key_result.is_err());
310        assert!(matches!(
311            private_key_result.unwrap_err(),
312            Error::SecureStoreDoesNotRevealSecretKey
313        ));
314    }
315
316    #[test]
317    fn test_secret_from_invalid_string() {
318        let secret = Secret::from_str("invalid");
319        assert!(secret.is_err());
320    }
321
322    #[test]
323    fn test_secure_store_toml_round_trip_with_cache() {
324        let secret = Secret::SecureStore {
325            entry_name: "secure_store:org.stellar.cli-alice".to_string(),
326            public_key: Some(TEST_PUBLIC_KEY.to_string()),
327            hd_path: None,
328        };
329        let serialized = toml::to_string(&secret).unwrap();
330        assert!(
331            serialized.contains("entry_name"),
332            "expected entry_name field in TOML, got: {serialized}"
333        );
334        assert!(
335            serialized.contains("public_key"),
336            "expected public_key field in TOML, got: {serialized}"
337        );
338        let parsed: Secret = toml::from_str(&serialized).unwrap();
339        assert_eq!(secret, parsed);
340    }
341
342    #[test]
343    fn test_secure_store_legacy_toml_parses_with_none_cache() {
344        // Identity files written before this feature only contain entry_name.
345        // They must still parse, with public_key/hd_path defaulting to None.
346        let toml_str = "entry_name = \"secure_store:org.stellar.cli-alice\"\n";
347        let secret: Secret = toml::from_str(toml_str).unwrap();
348        match secret {
349            Secret::SecureStore {
350                entry_name,
351                public_key,
352                hd_path,
353            } => {
354                assert_eq!(entry_name, "secure_store:org.stellar.cli-alice");
355                assert_eq!(public_key, None);
356                assert_eq!(hd_path, None);
357            }
358            other => panic!("expected SecureStore variant, got {other:?}"),
359        }
360    }
361
362    #[test]
363    fn test_secure_store_public_key_uses_cache_without_keychain_access() {
364        // A non-existent entry_name guarantees a keychain lookup would fail.
365        // The cached public_key should be returned without touching the keychain.
366        let secret = Secret::SecureStore {
367            entry_name: "secure_store:org.stellar.cli-no-such-entry".to_string(),
368            public_key: Some(TEST_PUBLIC_KEY.to_string()),
369            hd_path: None,
370        };
371        let pk = secret.public_key(None).unwrap();
372        assert_eq!(pk.to_string(), TEST_PUBLIC_KEY);
373    }
374
375    #[test]
376    fn test_secure_store_public_key_falls_back_to_persisted_hd_path() {
377        // Bogus entry_name guarantees a keychain lookup would fail. The cache is
378        // populated at the persisted hd_path; calling public_key(None) must fall
379        // back to that hd_path and hit the cache rather than re-deriving at index 0.
380        let secret = Secret::SecureStore {
381            entry_name: "secure_store:org.stellar.cli-no-such-entry".to_string(),
382            public_key: Some(TEST_PUBLIC_KEY.to_string()),
383            hd_path: Some(5),
384        };
385        let pk = secret.public_key(None).unwrap();
386        assert_eq!(pk.to_string(), TEST_PUBLIC_KEY);
387    }
388
389    #[test]
390    fn test_cached_public_key_treats_none_and_zero_as_equal() {
391        // `unwrap_or_default()` is used everywhere else for hd_path, so the
392        // cache must treat None and Some(0) as the same path.
393        assert!(cached_public_key(Some(TEST_PUBLIC_KEY), None, Some(0)).is_some());
394        assert!(cached_public_key(Some(TEST_PUBLIC_KEY), Some(0), None).is_some());
395        assert!(cached_public_key(Some(TEST_PUBLIC_KEY), None, None).is_some());
396        assert!(cached_public_key(Some(TEST_PUBLIC_KEY), Some(0), Some(0)).is_some());
397
398        // Different paths must still miss.
399        assert!(cached_public_key(Some(TEST_PUBLIC_KEY), None, Some(1)).is_none());
400        assert!(cached_public_key(Some(TEST_PUBLIC_KEY), Some(1), None).is_none());
401    }
402
403    #[test]
404    fn test_cached_public_key_treats_corrupt_value_as_miss() {
405        // A malformed cached value must be ignored so callers fall through to
406        // the keychain instead of erroring out.
407        assert!(cached_public_key(Some("not-a-public-key"), None, None).is_none());
408        assert!(cached_public_key(Some(""), None, None).is_none());
409    }
410
411    #[test]
412    fn test_seed_phrase_toml_round_trip_with_hd_path() {
413        let secret = Secret::SeedPhrase {
414            seed_phrase: TEST_SEED_PHRASE.to_string(),
415            hd_path: Some(5),
416        };
417        let serialized = toml::to_string(&secret).unwrap();
418        assert!(
419            serialized.contains("hd_path"),
420            "expected hd_path field in TOML, got: {serialized}"
421        );
422        let parsed: Secret = toml::from_str(&serialized).unwrap();
423        assert_eq!(secret, parsed);
424    }
425
426    #[test]
427    fn test_seed_phrase_legacy_toml_parses_with_none_hd_path() {
428        // Identity files written before this feature only contain seed_phrase.
429        // They must still parse, with hd_path defaulting to None.
430        let toml_str = format!("seed_phrase = \"{TEST_SEED_PHRASE}\"\n");
431        let secret: Secret = toml::from_str(&toml_str).unwrap();
432        match secret {
433            Secret::SeedPhrase {
434                seed_phrase,
435                hd_path,
436            } => {
437                assert_eq!(seed_phrase, TEST_SEED_PHRASE);
438                assert_eq!(hd_path, None);
439            }
440            other => panic!("expected SeedPhrase variant, got {other:?}"),
441        }
442    }
443
444    #[test]
445    fn test_seed_phrase_uses_persisted_hd_path_when_caller_passes_none() {
446        // When the caller passes None, the persisted hd_path should drive derivation.
447        let secret = Secret::SeedPhrase {
448            seed_phrase: TEST_SEED_PHRASE.to_string(),
449            hd_path: Some(1),
450        };
451        let pk_at_0 = secret.public_key(Some(0)).unwrap();
452        let pk_default = secret.public_key(None).unwrap();
453        assert_ne!(pk_at_0.to_string(), pk_default.to_string());
454    }
455
456    #[test]
457    fn test_seed_phrase_caller_hd_path_overrides_persisted() {
458        // Caller's explicit hd_path argument always wins over the persisted value.
459        let secret = Secret::SeedPhrase {
460            seed_phrase: TEST_SEED_PHRASE.to_string(),
461            hd_path: Some(1),
462        };
463        let pk = secret.public_key(Some(0)).unwrap();
464        let sk = secret.private_key(Some(0)).unwrap();
465        assert_eq!(pk.to_string(), TEST_PUBLIC_KEY);
466        assert_eq!(sk.to_string(), TEST_SECRET_KEY);
467    }
468
469    #[test]
470    fn test_ledger_toml_round_trip_with_hd_path() {
471        let secret = Secret::Ledger {
472            hardware: HardwareKind::Ledger,
473            public_key: TEST_PUBLIC_KEY.to_string(),
474            hd_path: Some(5),
475        };
476        let serialized = toml::to_string(&secret).unwrap();
477        assert!(
478            serialized.contains("hardware = \"ledger\""),
479            "expected `hardware = \"ledger\"` tag in TOML, got: {serialized}"
480        );
481        assert!(
482            serialized.contains("public_key"),
483            "expected public_key field in TOML, got: {serialized}"
484        );
485        assert!(
486            serialized.contains("hd_path"),
487            "expected hd_path field in TOML, got: {serialized}"
488        );
489        let parsed: Secret = toml::from_str(&serialized).unwrap();
490        assert_eq!(secret, parsed);
491    }
492
493    #[test]
494    fn test_ledger_toml_omits_hd_path_when_none() {
495        let secret = Secret::Ledger {
496            hardware: HardwareKind::Ledger,
497            public_key: TEST_PUBLIC_KEY.to_string(),
498            hd_path: None,
499        };
500        let serialized = toml::to_string(&secret).unwrap();
501        assert!(
502            !serialized.contains("hd_path"),
503            "expected no hd_path field in TOML when None, got: {serialized}"
504        );
505        let parsed: Secret = toml::from_str(&serialized).unwrap();
506        assert_eq!(secret, parsed);
507    }
508
509    #[test]
510    fn test_ledger_public_key_returns_cached_without_device() {
511        // No emulator/device available in this test; the cached public_key
512        // must be returned directly without attempting to query the device.
513        let secret = Secret::Ledger {
514            hardware: HardwareKind::Ledger,
515            public_key: TEST_PUBLIC_KEY.to_string(),
516            hd_path: Some(5),
517        };
518        let pk = secret.public_key(None).unwrap();
519        assert_eq!(pk.to_string(), TEST_PUBLIC_KEY);
520    }
521
522    #[test]
523    fn test_ledger_public_key_rejects_caller_hd_path() {
524        // The hd-path on a Ledger alias is fixed at `keys add` time; any
525        // caller-supplied --hd-path should error rather than silently using
526        // the cached value or attempting to override it. Discovery on other
527        // paths goes through `--ledger --hd-path N` instead.
528        let secret = Secret::Ledger {
529            hardware: HardwareKind::Ledger,
530            public_key: TEST_PUBLIC_KEY.to_string(),
531            hd_path: Some(5),
532        };
533        assert!(matches!(
534            secret.public_key(Some(5)).unwrap_err(),
535            Error::LedgerHdPathFixed,
536        ));
537        assert!(matches!(
538            secret.public_key(Some(7)).unwrap_err(),
539            Error::LedgerHdPathFixed,
540        ));
541    }
542
543    #[test]
544    fn test_ledger_public_key_uses_cached_path_when_caller_passes_none() {
545        let secret = Secret::Ledger {
546            hardware: HardwareKind::Ledger,
547            public_key: TEST_PUBLIC_KEY.to_string(),
548            hd_path: Some(5),
549        };
550        assert_eq!(
551            secret.public_key(None).unwrap().to_string(),
552            TEST_PUBLIC_KEY
553        );
554    }
555
556    #[test]
557    fn test_ledger_private_key_is_rejected() {
558        let secret = Secret::Ledger {
559            hardware: HardwareKind::Ledger,
560            public_key: TEST_PUBLIC_KEY.to_string(),
561            hd_path: None,
562        };
563        assert!(matches!(
564            secret.private_key(None).unwrap_err(),
565            Error::LedgerDoesNotRevealSecretKey,
566        ));
567    }
568
569    #[test]
570    fn test_ledger_toml_does_not_collide_with_secure_store() {
571        // SecureStore TOMLs (entry_name + optional cached public_key) must not
572        // be mis-deserialized as Ledger now that Ledger also carries public_key.
573        let toml_str = "entry_name = \"secure_store:org.stellar.cli-alice\"\n\
574                        public_key = \"GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ\"\n";
575        let secret: Secret = toml::from_str(toml_str).unwrap();
576        assert!(matches!(secret, Secret::SecureStore { .. }));
577    }
578}