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    /// When generating a secret key, which `hd_path` should be used from the original `seed_phrase`.
54    #[arg(long)]
55    pub hd_path: Option<usize>,
56
57    #[command(flatten)]
58    pub network: network::Args,
59
60    /// Fund generated key pair
61    #[arg(long, default_value = "false")]
62    pub fund: bool,
63
64    /// Overwrite existing identity if it already exists.
65    #[arg(long)]
66    pub overwrite: bool,
67}
68
69impl Cmd {
70    pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
71        let print = Print::new(global_args.quiet);
72
73        if self.config_locator.read_identity(&self.name).is_ok() {
74            if !self.overwrite {
75                return Err(Error::IdentityAlreadyExists(self.name.to_string()));
76            }
77
78            print.exclaimln(format!("Overwriting identity '{}'", &self.name.to_string()));
79        }
80
81        let secret = self.secret(&print)?;
82        let path = self.config_locator.write_identity(&self.name, &secret)?;
83        print.checkln(format!("Key saved with alias {} in {path:?}", self.name));
84
85        if self.fund {
86            self.fund(&secret, &print).await?;
87        }
88
89        Ok(())
90    }
91
92    async fn fund(&self, secret: &Secret, print: &Print) -> Result<(), Error> {
93        let addr = secret.public_key(self.hd_path)?;
94        let network = self.network.get(&self.config_locator)?;
95        let formatted_name = self.name.to_string();
96
97        match network.fund_address(&addr).await {
98            Ok(()) => print.checkln(format!(
99                "Account {} funded on {:?}",
100                formatted_name, network.network_passphrase
101            )),
102            Err(e) => {
103                tracing::trace!("Account funding error: {:?}", e);
104
105                print.errorln(format!(
106                    "Unable to fund account {} on {:?}",
107                    formatted_name, network.network_passphrase
108                ));
109            }
110        }
111
112        Ok(())
113    }
114
115    fn secret(&self, print: &Print) -> Result<Secret, Error> {
116        let seed_phrase = self.seed_phrase()?;
117        if self.secure_store {
118            let secret = secure_store::save_secret(print, &self.name, &seed_phrase)?;
119            Ok(secret.parse()?)
120        } else if self.as_secret {
121            let secret: Secret = seed_phrase.into();
122            Ok(secret.private_key(self.hd_path)?.into())
123        } else {
124            Ok(seed_phrase.into())
125        }
126    }
127
128    fn seed_phrase(&self) -> Result<SeedPhrase, Error> {
129        Ok(secret::seed_phrase_from_seed(self.seed.as_deref())?)
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use crate::config::{address::KeyName, key::Key, secret::Secret};
136
137    fn set_up_test() -> (super::locator::Args, super::Cmd) {
138        let temp_dir = tempfile::tempdir().unwrap();
139        let locator = super::locator::Args {
140            global: false,
141            config_dir: Some(temp_dir.path().to_path_buf()),
142        };
143
144        let cmd = super::Cmd {
145            name: KeyName("test_name".to_string()),
146            seed: None,
147            as_secret: false,
148            secure_store: false,
149            config_locator: locator.clone(),
150            hd_path: None,
151            network: super::network::Args::default(),
152            fund: false,
153            overwrite: false,
154        };
155
156        (locator, cmd)
157    }
158
159    fn global_args() -> super::global::Args {
160        super::global::Args {
161            quiet: true,
162            ..Default::default()
163        }
164    }
165
166    #[tokio::test]
167    async fn test_storing_secret_as_a_seed_phrase() {
168        let (test_locator, cmd) = set_up_test();
169        let global_args = global_args();
170
171        let result = cmd.run(&global_args).await;
172        assert!(result.is_ok());
173        let identity = test_locator.read_identity("test_name").unwrap();
174        assert!(matches!(identity, Key::Secret(Secret::SeedPhrase { .. })));
175    }
176
177    #[tokio::test]
178    async fn test_storing_secret_as_a_secret_key() {
179        let (test_locator, mut cmd) = set_up_test();
180        cmd.as_secret = true;
181        let global_args = global_args();
182
183        let result = cmd.run(&global_args).await;
184        assert!(result.is_ok());
185        let identity = test_locator.read_identity("test_name").unwrap();
186        assert!(matches!(identity, Key::Secret(Secret::SecretKey { .. })));
187    }
188
189    #[cfg(feature = "additional-libs")]
190    #[tokio::test]
191    async fn test_storing_secret_in_secure_store() {
192        use keyring::{mock, set_default_credential_builder};
193        set_default_credential_builder(mock::default_credential_builder());
194        let (test_locator, mut cmd) = set_up_test();
195        cmd.secure_store = true;
196        let global_args = global_args();
197
198        let result = cmd.run(&global_args).await;
199        assert!(result.is_ok());
200        let identity = test_locator.read_identity("test_name").unwrap();
201        assert!(matches!(identity, Key::Secret(Secret::SecureStore { .. })));
202    }
203
204    #[cfg(not(feature = "additional-libs"))]
205    #[tokio::test]
206    async fn test_storing_in_secure_store_returns_error_when_additional_libs_not_enabled() {
207        let (test_locator, mut cmd) = set_up_test();
208        cmd.secure_store = true;
209        let global_args = global_args();
210
211        let result = cmd.run(&global_args).await;
212        assert!(result.is_err());
213        assert_eq!(
214            result.unwrap_err().to_string(),
215            format!("Secure Store keys are not allowed: additional-libs feature must be enabled")
216        );
217
218        let identity_result = test_locator.read_identity("test_name");
219        assert!(identity_result.is_err());
220        assert_eq!(
221            identity_result.unwrap_err().to_string(),
222            format!("Failed to find config identity for test_name")
223        );
224    }
225}