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 #[arg(long)]
46 pub secret_key: bool,
47
48 #[arg(long)]
50 pub seed_phrase: bool,
51
52 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
74 hd_path: Option<u32>,
75 },
76 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 #[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
248fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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}