pijul_identity/
create.rs

1use super::load::path;
2use super::Complete;
3
4use std::io::Write;
5use std::{fs, path::PathBuf};
6
7use anyhow::{bail, Context};
8use keyring::Entry;
9use log::{debug, warn};
10use pijul_interaction::{Confirm, Input, Select};
11use thrussh_keys::key::PublicKey;
12
13impl Complete {
14    /// Prompt the user to make changes to an identity, returning the new identity
15    ///
16    /// # Arguments
17    /// * `replace_current` - The identity to replace
18    pub async fn prompt_changes(
19        &self,
20        to_replace: Option<String>,
21        link_remote: bool,
22    ) -> Result<Self, anyhow::Error> {
23        let mut new_identity = self.clone();
24        let will_replace = to_replace.is_some();
25
26        new_identity.name = Input::new()?
27            .with_prompt("Unique identity name")
28            .with_default(String::from("default"))
29            .with_allow_empty(false)
30            .with_initial_text(if will_replace {
31                self.name.clone()
32            } else {
33                String::new()
34            })
35            .with_validator(move |input: &String| -> Result<(), String> {
36                if input.contains(['/', '\\', '.']) {
37                    return Err("Name contains illegal characters".to_string());
38                }
39
40                match Self::load(input) {
41                    Ok(existing_identity) => {
42                        if let Some(name) = &to_replace {
43                            if name == input {
44                                // The user is trying to edit an existing identity
45                                Ok(())
46                            } else {
47                                // The user is editing an existing identity but trying to overwrite a different name
48                                Err(format!("The identity {existing_identity} already exists. Either remove the identity or edit it directly."))
49                            }
50                        } else {
51                            // The user is creating a new identity but trying to use an existing name
52                            Err(format!("The identity {existing_identity} already exists. Either remove the identity or edit it directly."))
53                        }
54                    }
55                    Err(_) => Ok(()),
56                }
57            })
58            .interact()?;
59
60        new_identity.config.author.display_name = Input::new()?
61            .with_prompt("Display name")
62            .with_allow_empty(true)
63            .with_initial_text(&self.config.author.display_name)
64            .interact()?;
65
66        new_identity.config.author.email = Input::new()?
67            .with_prompt("Email (leave blank for none)")
68            .with_allow_empty(true)
69            .with_initial_text(&self.config.author.email)
70            .with_validator(move |input: &String| -> Result<(), &str> {
71                if input.is_empty() || validator::validate_email(input) {
72                    Ok(())
73                } else {
74                    Err("Invalid email address")
75                }
76            })
77            .interact()?;
78
79        if Confirm::new()?
80            .with_prompt(&format!(
81                "Do you want to change the encryption? (Current status: {})",
82                self.credentials
83                    .clone()
84                    .unwrap()
85                    .secret_key
86                    .encryption
87                    .map_or("not encrypted", |_| "encrypted")
88            ))
89            .with_default(false)
90            .interact()?
91        {
92            new_identity.change_password()?;
93        }
94
95        // Update the expiry AFTER potential secret key reset
96        new_identity.prompt_expiry()?;
97
98        if link_remote {
99            if Confirm::new()?
100                .with_prompt("Do you want to link this identity to a remote?")
101                .with_default(true)
102                .interact()?
103            {
104                new_identity.prompt_remote().await?;
105            } else {
106                // The user wants an 'offline' identity, so make sure not to store login info
107                new_identity.config.key_path = None;
108                new_identity.config.author.username = String::new();
109                new_identity.config.author.origin = String::new();
110            }
111        }
112
113        new_identity.last_modified = chrono::offset::Utc::now();
114
115        Ok(new_identity)
116    }
117
118    async fn prompt_ssh(&mut self) -> Result<(), anyhow::Error> {
119        let mut ssh_agent = thrussh_keys::agent::client::AgentClient::connect_env().await?;
120        let identities = ssh_agent.request_identities().await?;
121        let ssh_dir = dirs_next::home_dir().unwrap().join(".ssh");
122
123        let selection = Select::new()?
124            .with_prompt("Select key")
125            .with_items(
126                &identities
127                    .iter()
128                    .map(|id| {
129                        format!(
130                            "{}: {} ({})",
131                            id.name(),
132                            id.fingerprint(),
133                            ssh_dir
134                                .join(match id {
135                                    PublicKey::Ed25519(_) =>
136                                        thrussh_keys::key::ED25519.identity_file(),
137                                    PublicKey::RSA { ref hash, .. } => hash.name().identity_file(),
138                                })
139                                .display(),
140                        )
141                    })
142                    .collect::<Vec<_>>(),
143            )
144            .with_default(0 as usize)
145            .interact()?;
146
147        self.config.key_path = Some(ssh_dir.join(match identities[selection] {
148            PublicKey::Ed25519(_) => thrussh_keys::key::ED25519.identity_file(),
149            PublicKey::RSA { ref hash, .. } => hash.name().identity_file(),
150        }));
151
152        Ok(())
153    }
154
155    async fn prompt_remote(&mut self) -> Result<(), anyhow::Error> {
156        self.config.author.username = Input::new()?
157            .with_prompt("Remote username")
158            .with_default(whoami::username())
159            .with_initial_text(&self.config.author.username)
160            .interact()?;
161
162        self.config.author.origin = Input::new()?
163            .with_prompt("Remote URL")
164            .with_initial_text(&self.config.author.origin)
165            .with_default(String::from("ssh.pijul.com"))
166            .interact()?;
167
168        if Confirm::new()?
169            .with_prompt(&format!(
170                "Do you want to change the default SSH key? (Current key: {})",
171                if let Some(path) = &self.config.key_path {
172                    format!("{path:#?}")
173                } else {
174                    String::from("none")
175                }
176            ))
177            .with_default(false)
178            .interact()?
179        {
180            self.prompt_ssh().await?;
181        }
182
183        debug!("prompt remote {:?}", self.config.author);
184
185        Ok(())
186    }
187
188    fn prompt_expiry(&mut self) -> Result<(), anyhow::Error> {
189        let expiry_message = self
190            .public_key
191            .expires
192            .map(|date| date.format("%Y-%m-%d %H:%M:%S").to_string());
193
194        self.public_key.expires = if Confirm::new()?
195            .with_prompt(format!(
196                "Do you want this key to expire? (Current expiry: {})",
197                expiry_message
198                    .clone()
199                    .unwrap_or_else(|| String::from("never"))
200            ))
201            .with_default(false)
202            .interact()?
203        {
204            let time_stamp: String = Input::new()?
205                .with_prompt("Expiry date (YYYY-MM-DD)")
206                .with_initial_text(expiry_message.unwrap_or_default())
207                .with_validator(move |input: &String| -> Result<(), &str> {
208                    let parsed_date = dateparser::parse_with_timezone(input, &chrono::offset::Utc);
209                    if parsed_date.is_err() {
210                        return Err("Invalid date");
211                    }
212
213                    let date = parsed_date.unwrap();
214                    if chrono::offset::Utc::now().timestamp_millis() > date.timestamp_millis() {
215                        Err("Date is in the past")
216                    } else {
217                        Ok(())
218                    }
219                })
220                .interact()?;
221
222            Some(dateparser::parse_with_timezone(
223                &time_stamp,
224                &chrono::offset::Utc,
225            )?)
226        } else {
227            None
228        };
229
230        Ok(())
231    }
232
233    fn write_config(&self, identity_dir: &PathBuf) -> Result<(), anyhow::Error> {
234        let config_data = toml::to_string_pretty(&self)?;
235        let mut config_file = std::fs::File::create(identity_dir.join("identity.toml"))?;
236        config_file.write_all(config_data.as_bytes())?;
237
238        Ok(())
239    }
240
241    fn write_secret_key(&self, identity_dir: &PathBuf) -> Result<(), anyhow::Error> {
242        let key_data = serde_json::to_string_pretty(&self.secret_key())?;
243        let mut key_file = std::fs::File::create(&identity_dir.join("secret_key.json"))?;
244        key_file.write_all(key_data.as_bytes())?;
245
246        Ok(())
247    }
248
249    /// Write a complete identity to disk.
250    fn write(&self) -> Result<(), anyhow::Error> {
251        if let Ok(existing_identity) = Self::load(&self.name) {
252            bail!("An identity with that name already exists: {existing_identity}");
253        }
254
255        // Write the relevant identity files
256        let identity_dir = path(&self.name, false)?;
257
258        std::fs::create_dir_all(&identity_dir)?;
259        self.write_config(&identity_dir)?;
260        self.write_secret_key(&identity_dir)?;
261
262        Ok(())
263    }
264
265    /// Create a complete identity, including writing to disk & exchanging key with origin.
266    ///
267    /// # Arguments
268    /// * `link_remote` - Override if the identity should be exchanged with the remote.
269    pub async fn create(&self, link_remote: bool) -> Result<(), anyhow::Error> {
270        // Prompt the user to edit changes interactively
271        let confirmed_identity = self.prompt_changes(None, link_remote).await?;
272        confirmed_identity.write()?;
273
274        Ok(())
275    }
276
277    /// Replace an existing identity with a new one.
278    ///
279    /// # Arguments
280    /// * `new_identity` - The new identity that will be created
281    pub fn replace_with(self, new_identity: Self) -> Result<Self, anyhow::Error> {
282        let changed_names = self.name != new_identity.name;
283
284        // If changing the identity name, remove old directory
285        if changed_names {
286            let old_identity_path = path(&self.name, true)?;
287            debug!("Removing old directory: {old_identity_path:?}");
288            fs::remove_dir_all(old_identity_path).context("Could not remove old identity.")?;
289
290            let new_identity_path = path(&new_identity.name, false)?;
291            debug!("Creating new directory: {new_identity_path:?}");
292            fs::create_dir_all(new_identity_path).context("Could not create new identity.")?;
293
294            new_identity.write()?;
295
296            // Delete the existing password
297            if let Err(e) = Entry::new("pijul", &self.name).and_then(|x| x.delete_password()) {
298                warn!("Unable to delete password: {e:?}");
299            }
300        } else {
301            // Write only the new data
302            let identity_dir = path(&new_identity.name, false)?;
303            if self.config != new_identity.config {
304                new_identity.write_config(&identity_dir)?;
305            }
306            if self.secret_key() != new_identity.secret_key() {
307                new_identity.write_secret_key(&identity_dir)?;
308            }
309        }
310
311        // Update the password
312        if let Some(password) = new_identity.credentials.clone().unwrap().password.get() {
313            if let Err(e) =
314                Entry::new("pijul", &new_identity.name).and_then(|x| x.set_password(&password))
315            {
316                warn!("Unable to set password: {e:?}");
317            }
318        } else if let Err(e) =
319            Entry::new("pijul", &new_identity.name).and_then(|x| x.delete_password())
320        {
321            warn!("Unable to delete password: {e:?}");
322        }
323
324        Ok(new_identity)
325    }
326}