soroban_cli/commands/keys/
generate.rs

1use clap::{arg, command};
2use sep5::SeedPhrase;
3
4use super::super::config::{
5    locator, network,
6    secret::{self, Secret},
7};
8
9use crate::{commands::global, config::address::KeyName, print::Print, signer::secure_store};
10
11#[derive(thiserror::Error, Debug)]
12pub enum Error {
13    #[error(transparent)]
14    Config(#[from] locator::Error),
15
16    #[error(transparent)]
17    Secret(#[from] secret::Error),
18
19    #[error(transparent)]
20    Network(#[from] network::Error),
21
22    #[error("An identity with the name '{0}' already exists")]
23    IdentityAlreadyExists(String),
24
25    #[error(transparent)]
26    SecureStore(#[from] secure_store::Error),
27}
28
29#[derive(Debug, clap::Parser, Clone)]
30#[group(skip)]
31#[allow(clippy::struct_excessive_bools)]
32pub struct Cmd {
33    /// Name of identity
34    pub name: KeyName,
35
36    /// Optional seed to use when generating seed phrase.
37    /// Random otherwise.
38    #[arg(long)]
39    pub seed: Option<String>,
40
41    /// Output the generated identity as a secret key
42    #[arg(long, short = 's')]
43    pub as_secret: bool,
44
45    /// Save the new key in your OS's credential secure store.
46    ///
47    /// 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.
48    #[arg(long)]
49    pub secure_store: bool,
50
51    #[command(flatten)]
52    pub config_locator: locator::Args,
53
54    /// When generating a secret key, which `hd_path` should be used from the original `seed_phrase`.
55    #[arg(long)]
56    pub hd_path: Option<usize>,
57
58    #[command(flatten)]
59    pub network: network::Args,
60
61    /// Fund generated key pair
62    #[arg(long, default_value = "false")]
63    pub fund: bool,
64
65    /// Overwrite existing identity if it already exists.
66    #[arg(long)]
67    pub overwrite: bool,
68}
69
70impl Cmd {
71    pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
72        let print = Print::new(global_args.quiet);
73
74        if self.config_locator.read_identity(&self.name).is_ok() {
75            if !self.overwrite {
76                return Err(Error::IdentityAlreadyExists(self.name.to_string()));
77            }
78
79            print.exclaimln(format!("Overwriting identity '{}'", &self.name.to_string()));
80        }
81
82        let secret = self.secret(&print)?;
83        let path = self.config_locator.write_identity(&self.name, &secret)?;
84        print.checkln(format!("Key saved with alias {} in {path:?}", self.name));
85
86        if self.fund {
87            self.fund(&secret, &print).await?;
88        }
89
90        Ok(())
91    }
92
93    async fn fund(&self, secret: &Secret, print: &Print) -> Result<(), Error> {
94        let addr = secret.public_key(self.hd_path)?;
95        let network = self.network.get(&self.config_locator)?;
96        let formatted_name = self.name.to_string();
97        network
98            .fund_address(&addr)
99            .await
100            .map_err(|e| {
101                tracing::warn!("fund_address failed: {e}");
102            })
103            .unwrap_or_default();
104        print.checkln(format!(
105            "Account {} funded on {:?}",
106            formatted_name, network.network_passphrase
107        ));
108        Ok(())
109    }
110
111    fn secret(&self, print: &Print) -> Result<Secret, Error> {
112        let seed_phrase = self.seed_phrase()?;
113        if self.secure_store {
114            let secret = secure_store::save_secret(print, &self.name, &seed_phrase)?;
115            Ok(secret.parse()?)
116        } else if self.as_secret {
117            let secret: Secret = seed_phrase.into();
118            Ok(secret.private_key(self.hd_path)?.into())
119        } else {
120            Ok(seed_phrase.into())
121        }
122    }
123
124    fn seed_phrase(&self) -> Result<SeedPhrase, Error> {
125        Ok(secret::seed_phrase_from_seed(self.seed.as_deref())?)
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use crate::config::{address::KeyName, key::Key, secret::Secret};
132
133    fn set_up_test() -> (super::locator::Args, super::Cmd) {
134        let temp_dir = tempfile::tempdir().unwrap();
135        let locator = super::locator::Args {
136            global: false,
137            config_dir: Some(temp_dir.path().to_path_buf()),
138        };
139
140        let cmd = super::Cmd {
141            name: KeyName("test_name".to_string()),
142            seed: None,
143            as_secret: false,
144            secure_store: false,
145            config_locator: locator.clone(),
146            hd_path: None,
147            network: super::network::Args::default(),
148            fund: false,
149            overwrite: false,
150        };
151
152        (locator, cmd)
153    }
154
155    fn global_args() -> super::global::Args {
156        super::global::Args {
157            quiet: true,
158            ..Default::default()
159        }
160    }
161
162    #[tokio::test]
163    async fn test_storing_secret_as_a_seed_phrase() {
164        let (test_locator, cmd) = set_up_test();
165        let global_args = global_args();
166
167        let result = cmd.run(&global_args).await;
168        assert!(result.is_ok());
169        let identity = test_locator.read_identity("test_name").unwrap();
170        assert!(matches!(identity, Key::Secret(Secret::SeedPhrase { .. })));
171    }
172
173    #[tokio::test]
174    async fn test_storing_secret_as_a_secret_key() {
175        let (test_locator, mut cmd) = set_up_test();
176        cmd.as_secret = true;
177        let global_args = global_args();
178
179        let result = cmd.run(&global_args).await;
180        assert!(result.is_ok());
181        let identity = test_locator.read_identity("test_name").unwrap();
182        assert!(matches!(identity, Key::Secret(Secret::SecretKey { .. })));
183    }
184
185    #[cfg(feature = "additional-libs")]
186    #[tokio::test]
187    async fn test_storing_secret_in_secure_store() {
188        use keyring::{mock, set_default_credential_builder};
189        set_default_credential_builder(mock::default_credential_builder());
190        let (test_locator, mut cmd) = set_up_test();
191        cmd.secure_store = true;
192        let global_args = global_args();
193
194        let result = cmd.run(&global_args).await;
195        assert!(result.is_ok());
196        let identity = test_locator.read_identity("test_name").unwrap();
197        assert!(matches!(identity, Key::Secret(Secret::SecureStore { .. })));
198    }
199
200    #[cfg(not(feature = "additional-libs"))]
201    #[tokio::test]
202    async fn test_storing_in_secure_store_returns_error_when_additional_libs_not_enabled() {
203        let (test_locator, mut cmd) = set_up_test();
204        cmd.secure_store = true;
205        let global_args = global_args();
206
207        let result = cmd.run(&global_args).await;
208        assert!(result.is_err());
209        assert_eq!(
210            result.unwrap_err().to_string(),
211            format!("Secure Store keys are not allowed: additional-libs feature must be enabled")
212        );
213
214        let identity_result = test_locator.read_identity("test_name");
215        assert!(identity_result.is_err());
216        assert_eq!(
217            identity_result.unwrap_err().to_string(),
218            format!("Failed to find config identity for test_name")
219        );
220    }
221}