1use std::io::{IsTerminal, Write};
2
3use sep5::SeedPhrase;
4
5use crate::{
6 commands::global,
7 config::{
8 address::KeyName,
9 key, locator,
10 secret::{self, HardwareKind, Secret},
11 },
12 print::Print,
13 signer::{ledger, secure_store},
14};
15
16#[derive(thiserror::Error, Debug)]
17pub enum Error {
18 #[error(transparent)]
19 Secret(#[from] secret::Error),
20 #[error(transparent)]
21 Key(#[from] key::Error),
22 #[error(transparent)]
23 Config(#[from] locator::Error),
24
25 #[error(transparent)]
26 SecureStore(#[from] secure_store::Error),
27
28 #[error(transparent)]
29 SeedPhrase(#[from] sep5::error::Error),
30
31 #[error(transparent)]
32 Ledger(#[from] ledger::Error),
33
34 #[error("secret input error")]
35 PasswordRead,
36
37 #[error("An identity with the name '{0}' already exists")]
38 IdentityAlreadyExists(String),
39
40 #[error(
41 "--secure-store only supports seed phrases; \
42 unset STELLAR_SECRET_KEY or provide a seed phrase instead"
43 )]
44 SecureStoreRequiresSeedPhrase,
45
46 #[error("--hd-path is not valid with a secret key; secret keys cannot be derived")]
47 HdPathNotSupportedForSecretKey,
48}
49
50#[derive(Debug, clap::Parser, Clone)]
51#[group(skip)]
52pub struct Cmd {
53 pub name: KeyName,
55
56 #[command(flatten)]
57 pub secrets: secret::Args,
58
59 #[command(flatten)]
60 pub config_locator: locator::Args,
61
62 #[arg(
64 long,
65 conflicts_with = "seed_phrase",
66 conflicts_with = "secret_key",
67 conflicts_with = "hd_path",
68 conflicts_with = "ledger"
69 )]
70 pub public_key: Option<String>,
71
72 #[arg(
77 long,
78 conflicts_with = "secret_key",
79 conflicts_with = "seed_phrase",
80 conflicts_with = "secure_store"
81 )]
82 pub ledger: bool,
83
84 #[arg(long)]
87 pub overwrite: bool,
88
89 #[arg(long)]
94 pub hd_path: Option<u32>,
95}
96
97impl Cmd {
98 pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
99 let print = Print::new(global_args.quiet);
100
101 if self.config_locator.read_identity(&self.name).is_ok() {
102 if !self.overwrite {
103 return Err(Error::IdentityAlreadyExists(self.name.to_string()));
104 }
105
106 print.exclaimln(format!("Overwriting identity '{}'", &self.name.to_string()));
107 }
108
109 let key = if let Some(key) = self.public_key.as_ref() {
110 key::Key::parse_public_only(key)?
111 } else if self.ledger {
112 self.derive_ledger_secret().await?.into()
113 } else {
114 self.read_secret(&print)?.into()
115 };
116
117 let path = self.config_locator.write_key(&self.name, &key)?;
118
119 print.checkln(format!("Key saved with alias {} in {path:?}", self.name));
120
121 Ok(())
122 }
123
124 async fn derive_ledger_secret(&self) -> Result<Secret, Error> {
125 let public_key = ledger::new(self.hd_path.unwrap_or_default())
126 .await?
127 .public_key()
128 .await?;
129 Ok(Secret::Ledger {
130 hardware: HardwareKind::Ledger,
131 public_key: format!("{public_key}"),
132 hd_path: self.hd_path,
133 })
134 }
135
136 fn read_secret(&self, print: &Print) -> Result<Secret, Error> {
137 if self.secrets.secure_store {
138 if std::env::var("STELLAR_SECRET_KEY").is_ok() {
139 return Err(Error::SecureStoreRequiresSeedPhrase);
140 }
141 } else if let Ok(secret_key) = std::env::var("STELLAR_SECRET_KEY") {
142 return build_secret(&secret_key, self.hd_path);
143 }
144
145 if self.secrets.secure_store {
146 let prompt = "Type a 12/24 word seed phrase:";
147 let secret_key = read_password(print, prompt)?;
148 if secret_key.split_whitespace().count() < 24 {
149 print.warnln("The provided seed phrase lacks sufficient entropy and should be avoided. Using a 24-word seed phrase is a safer option.".to_string());
150 print.warnln(
151 "To generate a new key, use the `stellar keys generate` command.".to_string(),
152 );
153 }
154
155 let seed_phrase: SeedPhrase = secret_key.parse()?;
156
157 Ok(secure_store::save_secret(
158 print,
159 &self.name,
160 &seed_phrase,
161 self.hd_path,
162 self.overwrite,
163 )?)
164 } else {
165 let prompt = "Type a secret key or 12/24 word seed phrase:";
166 let secret_key = read_password(print, prompt)?;
167 let secret = build_secret(&secret_key, self.hd_path)?;
168 if let Secret::SeedPhrase { seed_phrase, .. } = &secret {
169 if seed_phrase.split_whitespace().count() < 24 {
170 print.warnln("The provided seed phrase lacks sufficient entropy and should be avoided. Using a 24-word seed phrase is a safer option.".to_string());
171 print.warnln(
172 "To generate a new key, use the `stellar keys generate` command."
173 .to_string(),
174 );
175 }
176 }
177 Ok(secret)
178 }
179 }
180}
181
182fn build_secret(input: &str, hd_path: Option<u32>) -> Result<Secret, Error> {
183 let secret: Secret = input.parse()?;
184 match (secret, hd_path) {
185 (Secret::SecretKey { .. }, Some(_)) => Err(Error::HdPathNotSupportedForSecretKey),
186 (Secret::SeedPhrase { seed_phrase, .. }, hd_path) => Ok(Secret::SeedPhrase {
187 seed_phrase,
188 hd_path,
189 }),
190 (secret, _) => Ok(secret),
191 }
192}
193
194fn read_password(print: &Print, prompt: &str) -> Result<String, Error> {
195 if std::io::stdin().is_terminal() {
196 print.arrowln(prompt);
198 std::io::stdout().flush().map_err(|_| Error::PasswordRead)?;
199 rpassword::read_password().map_err(|_| Error::PasswordRead)
200 } else {
201 let mut input = String::new();
203 std::io::stdin()
204 .read_line(&mut input)
205 .map_err(|_| Error::PasswordRead)?;
206 let input = input.trim().to_string();
207 if input.is_empty() {
208 return Err(Error::PasswordRead);
209 }
210 Ok(input)
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use crate::config::key::{self as key_mod, Key};
218
219 const PUBLIC_KEY: &str = "GAKSH6AD2IPJQELTHIOWDAPYX74YELUOWJLI2L4RIPIPZH6YQIFNUSDC";
220 const MUXED_ACCOUNT: &str =
221 "MA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAAAAAAAAAPCICBKU";
222 const SECRET_KEY: &str = "SBF5HLRREHMS36XZNTUSKZ6FTXDZGNXOHF4EXKUL5UCWZLPBX3NGJ4BH";
223 const SEED_PHRASE: &str =
224 "depth decade power loud smile spatial sign movie judge february rate broccoli";
225
226 fn set_up_test() -> (tempfile::TempDir, locator::Args, Cmd) {
227 let temp_dir = tempfile::tempdir().unwrap();
228 let locator = locator::Args {
229 config_dir: Some(temp_dir.path().to_path_buf()),
230 };
231 let cmd = Cmd {
232 name: "test_name".parse().unwrap(),
233 secrets: secret::Args {
234 secret_key: false,
235 seed_phrase: false,
236 secure_store: false,
237 },
238 config_locator: locator.clone(),
239 public_key: None,
240 ledger: false,
241 overwrite: false,
242 hd_path: None,
243 };
244 (temp_dir, locator, cmd)
245 }
246
247 fn cmd_with_public_key(
248 public_key: &str,
249 hd_path: Option<u32>,
250 ) -> (tempfile::TempDir, locator::Args, Cmd) {
251 let (temp_dir, locator, mut cmd) = set_up_test();
252 cmd.public_key = Some(public_key.to_string());
253 cmd.hd_path = hd_path;
254 (temp_dir, locator, cmd)
255 }
256
257 fn global_args() -> global::Args {
258 global::Args {
259 quiet: true,
260 ..Default::default()
261 }
262 }
263
264 #[test]
265 fn test_build_secret_persists_hd_path_on_seed_phrase() {
266 let secret = build_secret(SEED_PHRASE, Some(5)).unwrap();
267 match secret {
268 Secret::SeedPhrase {
269 seed_phrase,
270 hd_path,
271 } => {
272 assert_eq!(seed_phrase, SEED_PHRASE);
273 assert_eq!(hd_path, Some(5));
274 }
275 other => panic!("expected SeedPhrase variant, got {other:?}"),
276 }
277 }
278
279 #[test]
280 fn test_build_secret_seed_phrase_without_hd_path() {
281 let secret = build_secret(SEED_PHRASE, None).unwrap();
282 match secret {
283 Secret::SeedPhrase { hd_path, .. } => assert_eq!(hd_path, None),
284 other => panic!("expected SeedPhrase variant, got {other:?}"),
285 }
286 }
287
288 #[test]
289 fn test_build_secret_rejects_hd_path_with_secret_key() {
290 let result = build_secret(SECRET_KEY, Some(5));
291 assert!(matches!(result, Err(Error::HdPathNotSupportedForSecretKey)));
292 }
293
294 #[test]
295 fn test_build_secret_secret_key_without_hd_path() {
296 let secret = build_secret(SECRET_KEY, None).unwrap();
297 assert!(matches!(secret, Secret::SecretKey { .. }));
298 }
299
300 #[test]
301 fn test_clap_rejects_hd_path_with_public_key() {
302 use clap::Parser;
306
307 let result = Cmd::try_parse_from([
308 "add",
309 "test_name",
310 "--public-key",
311 PUBLIC_KEY,
312 "--hd-path",
313 "3",
314 ]);
315 let err = result.expect_err("clap must reject --public-key + --hd-path");
316 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
317 }
318
319 #[test]
320 fn test_clap_accepts_ledger_with_hd_path() {
321 use clap::Parser;
322
323 let cmd = Cmd::try_parse_from(["add", "test_name", "--ledger", "--hd-path", "5"])
324 .expect("--ledger + --hd-path must parse");
325 assert!(cmd.ledger);
326 assert_eq!(cmd.hd_path, Some(5));
327 }
328
329 #[test]
330 fn test_clap_rejects_ledger_with_public_key() {
331 use clap::Parser;
332
333 let err = Cmd::try_parse_from(["add", "test_name", "--ledger", "--public-key", PUBLIC_KEY])
334 .expect_err("clap must reject --ledger + --public-key");
335 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
336 }
337
338 #[test]
339 fn test_clap_rejects_ledger_with_secret_key() {
340 use clap::Parser;
341
342 let err = Cmd::try_parse_from(["add", "test_name", "--ledger", "--secret-key"])
343 .expect_err("clap must reject --ledger + --secret-key");
344 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
345 }
346
347 #[test]
348 fn test_clap_rejects_ledger_with_seed_phrase() {
349 use clap::Parser;
350
351 let err = Cmd::try_parse_from(["add", "test_name", "--ledger", "--seed-phrase"])
352 .expect_err("clap must reject --ledger + --seed-phrase");
353 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
354 }
355
356 #[test]
357 fn test_clap_rejects_ledger_with_secure_store() {
358 use clap::Parser;
359
360 let err = Cmd::try_parse_from(["add", "test_name", "--ledger", "--secure-store"])
361 .expect_err("clap must reject --ledger + --secure-store");
362 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
363 }
364
365 #[tokio::test]
366 async fn test_run_accepts_public_key_without_hd_path() {
367 let (_tmp, _locator, cmd) = cmd_with_public_key(PUBLIC_KEY, None);
368 assert!(cmd.run(&global_args()).await.is_ok());
369 }
370
371 #[tokio::test]
372 async fn public_key_flag_accepts_public_key() {
373 let (_tmp, locator, mut cmd) = set_up_test();
374 cmd.public_key = Some(PUBLIC_KEY.to_string());
375 cmd.run(&global_args()).await.unwrap();
376 let stored = locator.read_identity("test_name").unwrap();
377 assert!(matches!(stored, Key::PublicKey(_)));
378 }
379
380 #[tokio::test]
381 async fn public_key_flag_accepts_muxed_account() {
382 let (_tmp, locator, mut cmd) = set_up_test();
383 cmd.public_key = Some(MUXED_ACCOUNT.to_string());
384 cmd.run(&global_args()).await.unwrap();
385 let stored = locator.read_identity("test_name").unwrap();
386 assert!(matches!(stored, Key::MuxedAccount(_)));
387 }
388
389 #[tokio::test]
390 async fn public_key_flag_rejects_secret_key() {
391 let (_tmp, locator, mut cmd) = set_up_test();
392 cmd.public_key = Some(SECRET_KEY.to_string());
393 let err = cmd.run(&global_args()).await.unwrap_err();
394 assert!(matches!(err, Error::Key(key_mod::Error::PublicKeyExpected)));
395 assert!(locator.read_identity("test_name").is_err());
396 }
397
398 #[tokio::test]
399 async fn public_key_flag_rejects_seed_phrase() {
400 let (_tmp, locator, mut cmd) = set_up_test();
401 cmd.public_key = Some(SEED_PHRASE.to_string());
402 let err = cmd.run(&global_args()).await.unwrap_err();
403 assert!(matches!(err, Error::Key(key_mod::Error::PublicKeyExpected)));
404 assert!(locator.read_identity("test_name").is_err());
405 }
406
407 #[tokio::test]
408 async fn public_key_flag_rejects_ledger() {
409 let (_tmp, locator, mut cmd) = set_up_test();
410 cmd.public_key = Some("ledger".to_string());
411 let err = cmd.run(&global_args()).await.unwrap_err();
412 assert!(matches!(err, Error::Key(key_mod::Error::Parse)));
413 assert!(locator.read_identity("test_name").is_err());
414 }
415
416 #[tokio::test]
417 async fn public_key_flag_rejects_secure_store() {
418 let (_tmp, locator, mut cmd) = set_up_test();
419 cmd.public_key = Some("secure_store:org.stellar.cli-alice".to_string());
420 let err = cmd.run(&global_args()).await.unwrap_err();
421 assert!(matches!(err, Error::Key(key_mod::Error::PublicKeyExpected)));
422 assert!(locator.read_identity("test_name").is_err());
423 }
424}