Skip to main content

soroban_cli/commands/keys/
generate.rs

1use sep5::SeedPhrase;
2
3use super::super::config::{
4    locator, network,
5    secret::{self, Secret},
6};
7
8use crate::{commands::global, config::address::KeyName, print::Print, signer::secure_store};
9
10#[derive(thiserror::Error, Debug)]
11pub enum Error {
12    #[error(transparent)]
13    Config(#[from] locator::Error),
14
15    #[error(transparent)]
16    Secret(#[from] secret::Error),
17
18    #[error(transparent)]
19    Network(#[from] network::Error),
20
21    #[error("An identity with the name '{0}' already exists")]
22    IdentityAlreadyExists(String),
23
24    #[error(transparent)]
25    SecureStore(#[from] secure_store::Error),
26}
27
28#[derive(Debug, clap::Parser, Clone)]
29#[group(skip)]
30#[allow(clippy::struct_excessive_bools)]
31pub struct Cmd {
32    /// Name of identity
33    pub name: KeyName,
34
35    /// Optional seed to use when generating seed phrase.
36    /// Random otherwise.
37    #[arg(long)]
38    pub seed: Option<String>,
39
40    /// Output the generated identity as a secret key
41    #[arg(long, short = 's')]
42    pub as_secret: bool,
43
44    /// Save the new key in your OS's credential secure store.
45    ///
46    /// 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.
47    #[arg(long)]
48    pub secure_store: bool,
49
50    #[command(flatten)]
51    pub config_locator: locator::Args,
52
53    /// Which `hd_path` to derive the key at from the seed phrase. Honored across all
54    /// storage modes: with `--as-secret` it picks which derived key is stored, with
55    /// `--secure-store` or plain seed-phrase storage it is persisted on the identity
56    /// so later commands derive the same account without re-passing the flag.
57    #[arg(long)]
58    pub hd_path: Option<u32>,
59
60    #[command(flatten)]
61    pub network: network::Args,
62
63    /// Fund generated key pair
64    #[arg(long, default_value = "false")]
65    pub fund: bool,
66
67    /// Overwrite existing identity if it already exists. When combined with
68    /// --secure-store, also replaces the existing Secure Store entry.
69    #[arg(long)]
70    pub overwrite: bool,
71}
72
73impl Cmd {
74    pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
75        let print = Print::new(global_args.quiet);
76
77        if self.config_locator.read_identity(&self.name).is_ok() {
78            if !self.overwrite {
79                return Err(Error::IdentityAlreadyExists(self.name.to_string()));
80            }
81
82            print.exclaimln(format!("Overwriting identity '{}'", &self.name.to_string()));
83        }
84
85        let secret = self.secret(&print)?;
86        let path = self.config_locator.write_identity(&self.name, &secret)?;
87        print.checkln(format!("Key saved with alias {} in {path:?}", self.name));
88
89        if self.fund {
90            self.fund(&secret, &print).await?;
91        }
92
93        Ok(())
94    }
95
96    async fn fund(&self, secret: &Secret, print: &Print) -> Result<(), Error> {
97        let addr = secret.public_key(self.hd_path)?;
98        let network = self.network.get(&self.config_locator)?;
99        let formatted_name = self.name.to_string();
100
101        match network.fund_address(&addr).await {
102            Ok(()) => print.checkln(format!(
103                "Account {} funded on {:?}",
104                formatted_name, network.network_passphrase
105            )),
106            Err(e) => {
107                tracing::trace!("Account funding error: {:?}", e);
108
109                print.errorln(format!(
110                    "Unable to fund account {} on {:?}",
111                    formatted_name, network.network_passphrase
112                ));
113            }
114        }
115
116        Ok(())
117    }
118
119    fn secret(&self, print: &Print) -> Result<Secret, Error> {
120        let seed_phrase = self.seed_phrase()?;
121        if self.secure_store {
122            Ok(secure_store::save_secret(
123                print,
124                &self.name,
125                &seed_phrase,
126                self.hd_path,
127                self.overwrite,
128            )?)
129        } else if self.as_secret {
130            let secret: Secret = seed_phrase.into();
131            Ok(secret.private_key(self.hd_path)?.into())
132        } else {
133            Ok(Secret::SeedPhrase {
134                seed_phrase: seed_phrase.seed_phrase.into_phrase(),
135                hd_path: self.hd_path,
136            })
137        }
138    }
139
140    fn seed_phrase(&self) -> Result<SeedPhrase, Error> {
141        Ok(secret::seed_phrase_from_seed(self.seed.as_deref())?)
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use crate::config::{address::KeyName, key::Key, secret::Secret};
148
149    fn set_up_test() -> (super::locator::Args, super::Cmd, tempfile::TempDir) {
150        let temp_dir = tempfile::tempdir().unwrap();
151        let locator = super::locator::Args {
152            config_dir: Some(temp_dir.path().to_path_buf()),
153        };
154
155        let cmd = super::Cmd {
156            name: KeyName("test_name".to_string()),
157            seed: None,
158            as_secret: false,
159            secure_store: false,
160            config_locator: locator.clone(),
161            hd_path: None,
162            network: super::network::Args::default(),
163            fund: false,
164            overwrite: false,
165        };
166
167        (locator, cmd, temp_dir)
168    }
169
170    fn global_args() -> super::global::Args {
171        super::global::Args {
172            quiet: true,
173            ..Default::default()
174        }
175    }
176
177    #[tokio::test]
178    async fn test_storing_secret_as_a_seed_phrase() {
179        let (test_locator, cmd, _temp_dir) = set_up_test();
180        let global_args = global_args();
181
182        let result = cmd.run(&global_args).await;
183        assert!(result.is_ok());
184        let identity = test_locator.read_identity("test_name").unwrap();
185        assert!(matches!(identity, Key::Secret(Secret::SeedPhrase { .. })));
186    }
187
188    #[tokio::test]
189    async fn test_generate_seed_phrase_persists_hd_path() {
190        let (test_locator, mut cmd, _temp_dir) = set_up_test();
191        cmd.hd_path = Some(7);
192        let global_args = global_args();
193
194        cmd.run(&global_args).await.unwrap();
195
196        let identity = test_locator.read_identity("test_name").unwrap();
197        match identity {
198            Key::Secret(Secret::SeedPhrase { hd_path, .. }) => {
199                assert_eq!(hd_path, Some(7));
200            }
201            other => panic!("expected SeedPhrase variant, got {other:?}"),
202        }
203    }
204
205    #[tokio::test]
206    async fn test_storing_secret_as_a_secret_key() {
207        let (test_locator, mut cmd, _temp_dir) = set_up_test();
208        cmd.as_secret = true;
209        let global_args = global_args();
210
211        let result = cmd.run(&global_args).await;
212        assert!(result.is_ok());
213        let identity = test_locator.read_identity("test_name").unwrap();
214        assert!(matches!(identity, Key::Secret(Secret::SecretKey { .. })));
215    }
216
217    #[cfg(feature = "additional-libs")]
218    #[tokio::test]
219    async fn test_storing_secret_in_secure_store() {
220        use keyring::{mock, set_default_credential_builder};
221        set_default_credential_builder(mock::default_credential_builder());
222        let (test_locator, mut cmd, _temp_dir) = set_up_test();
223        cmd.secure_store = true;
224        let global_args = global_args();
225
226        let result = cmd.run(&global_args).await;
227        assert!(result.is_ok());
228        let identity = test_locator.read_identity("test_name").unwrap();
229        assert!(matches!(identity, Key::Secret(Secret::SecureStore { .. })));
230    }
231
232    #[cfg(feature = "additional-libs")]
233    #[tokio::test]
234    async fn test_generate_secure_store_caches_public_key_on_disk() {
235        use keyring::{mock, set_default_credential_builder};
236        set_default_credential_builder(mock::default_credential_builder());
237        let (test_locator, mut cmd, _temp_dir) = set_up_test();
238        cmd.secure_store = true;
239        let global_args = global_args();
240
241        cmd.run(&global_args).await.unwrap();
242
243        let identity = test_locator.read_identity("test_name").unwrap();
244        match identity {
245            Key::Secret(Secret::SecureStore {
246                public_key,
247                hd_path,
248                ..
249            }) => {
250                assert!(
251                    public_key.is_some(),
252                    "public_key should be cached on disk after `keys generate --secure-store`"
253                );
254                assert_eq!(hd_path, None);
255            }
256            other => panic!("expected SecureStore variant, got {other:?}"),
257        }
258    }
259
260    #[cfg(not(feature = "additional-libs"))]
261    #[tokio::test]
262    async fn test_storing_in_secure_store_returns_error_when_additional_libs_not_enabled() {
263        let (test_locator, mut cmd, _temp_dir) = set_up_test();
264        cmd.secure_store = true;
265        let global_args = global_args();
266
267        let result = cmd.run(&global_args).await;
268        assert!(result.is_err());
269        assert_eq!(
270            result.unwrap_err().to_string(),
271            format!("Secure Store keys are not allowed: additional-libs feature must be enabled")
272        );
273
274        let identity_result = test_locator.read_identity("test_name");
275        assert!(identity_result.is_err());
276        assert_eq!(
277            identity_result.unwrap_err().to_string(),
278            format!("Failed to find config identity for test_name")
279        );
280    }
281}