Skip to main content

soroban_cli/commands/keys/
add.rs

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    /// Name of identity
54    pub name: KeyName,
55
56    #[command(flatten)]
57    pub secrets: secret::Args,
58
59    #[command(flatten)]
60    pub config_locator: locator::Args,
61
62    /// Add a public key, ed25519, or muxed account, e.g. G1.., M2..
63    #[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    /// Derive the address from a connected Ledger hardware wallet at
73    /// `m/44'/148'/N'`, where `N` defaults to 0 and can be set with
74    /// `--hd-path`. Persists the derived public key (and `--hd-path`,
75    /// when provided) so later commands work without the device.
76    #[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    /// Overwrite existing identity if it already exists. When combined with
85    /// --secure-store, also replaces the existing Secure Store entry.
86    #[arg(long)]
87    pub overwrite: bool,
88
89    /// When importing a seed phrase, which `hd_path` to derive the key at.
90    /// Persisted on the identity so later commands derive the same account
91    /// without re-passing the flag. Not valid with `--public-key` or a raw
92    /// secret key.
93    #[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        // Interactive: prompt and read from TTY
197        print.arrowln(prompt);
198        std::io::stdout().flush().map_err(|_| Error::PasswordRead)?;
199        rpassword::read_password().map_err(|_| Error::PasswordRead)
200    } else {
201        // Non-interactive: read from stdin
202        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        // clap-level conflict: --public-key cannot be combined with --hd-path.
303        // Driving through `try_parse_from` rather than constructing `Cmd`
304        // directly is what exercises the conflict.
305        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}