pijul_identity/
lib.rs

1//! Complete identity management.
2//!
3//! Pijul uses keys, rather than personal details such as names or emails to attribute changes.
4//! The user can have multiple identities on disk, each with completely unique details. For more
5//! information see [the manual](https://pijul.com/manual/keys.html).
6//!
7//! This module implements various functionality useful for managing identities on disk.
8//! The current format for storing identities is as follows:
9//! ```md
10//! .config/pijul/ (or applicable global config directory)
11//! ├── config.toml (global defaults)
12//! │   ├── Username
13//! │   ├── Full name
14//! │   └── Email
15//! └── identities/
16//!     └── <IDENTITY NAME>/
17//!         ├── identity.toml
18//!         │   ├── Username
19//!         │   ├── Full name
20//!         │   ├── Email
21//!         │   └── Public key
22//!         │       ├── Version
23//!         │       ├── Algorithm
24//!         │       ├── Key
25//!         │       └── Signature
26//!         └── secret_key.json
27//!             ├── Version
28//!             ├── Algorithm
29//!             └── Key
30//! ```
31
32#![deny(clippy::all)]
33#![warn(clippy::pedantic)]
34#![warn(clippy::nursery)]
35#![warn(clippy::cargo)]
36
37mod create;
38mod load;
39mod repair;
40
41pub use load::{choose_identity_name, public_key};
42use log::warn;
43pub use repair::fix_identities;
44
45use pijul_config as config;
46use pijul_config::Author;
47
48use libpijul::key::{PublicKey, SKey, SecretKey};
49
50use std::fmt::Display;
51use std::fs;
52use std::io::{Read, Write};
53use std::path::PathBuf;
54
55use pijul_interaction::Password;
56use serde::{Deserialize, Serialize};
57use std::sync::OnceLock;
58
59#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
60pub struct Config {
61    #[serde(flatten)]
62    pub author: Author,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub key_path: Option<PathBuf>,
65}
66
67impl Default for Config {
68    fn default() -> Self {
69        Self {
70            key_path: None,
71            author: Author::default(),
72        }
73    }
74}
75
76impl From<Author> for Config {
77    fn from(author: Author) -> Self {
78        Self {
79            key_path: None,
80            author,
81        }
82    }
83}
84
85#[derive(Clone, Debug)]
86pub struct Credentials {
87    secret_key: SecretKey,
88    password: OnceLock<String>,
89}
90
91impl Credentials {
92    pub fn new(secret_key: SecretKey, password: Option<String>) -> Self {
93        Self {
94            secret_key,
95            password: if let Some(pw) = password {
96                OnceLock::from(pw)
97            } else {
98                OnceLock::new()
99            },
100        }
101    }
102}
103
104impl From<SecretKey> for Credentials {
105    fn from(secret_key: SecretKey) -> Self {
106        Self {
107            secret_key,
108            password: OnceLock::new(),
109        }
110    }
111}
112
113impl Credentials {
114    pub fn decrypt(&mut self, name: &str) -> Result<(SKey, Option<String>), anyhow::Error> {
115        if self.secret_key.encryption.is_none() {
116            // Don't mind what the given password is, the secret key has no encryption
117            // Make sure to revoke the password
118            self.password.take();
119            Ok((self.secret_key.load(None)?, None))
120        } else if let Ok(key) = self
121            .secret_key
122            .load(self.password.get().map(String::as_str))
123        {
124            // The password matches secret key, no extra work needed
125            Ok((key, self.password.get().map(|x| x.to_owned())))
126        } else {
127            // Password does not match secret key
128            let mut stderr = std::io::stderr();
129            let mut password_attempt = String::new();
130
131            // Try a password stored in the keychain
132            if let Ok(password) = keyring::Entry::new("pijul", name).and_then(|x| x.get_password())
133            {
134                password_attempt = password;
135            }
136
137            // Re-prompt as long as the password doesn't work
138            while self.secret_key.load(Some(&password_attempt)).is_err() {
139                writeln!(stderr, "Password does not match secret key")?;
140
141                password_attempt = Password::new()?
142                    .with_prompt("Password for secret key")
143                    .with_allow_empty(true)
144                    .interact()?;
145            }
146
147            // Update the password
148            if let Err(e) =
149                keyring::Entry::new("pijul", name).and_then(|x| x.set_password(&password_attempt))
150            {
151                warn!("Unable to set password: {e:?}");
152            }
153            self.password.set(password_attempt.clone()).unwrap();
154
155            Ok((
156                self.secret_key.load(Some(&password_attempt))?,
157                Some(password_attempt),
158            ))
159        }
160    }
161}
162
163#[derive(Clone, Debug, Serialize, Deserialize)]
164/// A complete user identity, representing the secret key, public key, and user info
165pub struct Complete {
166    #[serde(skip)]
167    pub name: String,
168    #[serde(flatten)]
169    pub config: Config,
170    pub last_modified: chrono::DateTime<chrono::Utc>,
171    pub public_key: PublicKey,
172    #[serde(skip)]
173    pub credentials: Option<Credentials>,
174}
175
176impl Complete {
177    /// Creates a new identity
178    ///
179    /// # Arguments
180    /// * `name` - The name of the identity. This is encoded on-disk as identities/`<NAME>`
181    /// * `config` - User configuration including author details & SSH key
182    /// * `public_key` - The user's public key
183    /// * `credentials` - The user's secret data including secret key & password
184    pub fn new(
185        name: String,
186        config: Config,
187        public_key: PublicKey,
188        credentials: Option<Credentials>,
189    ) -> Self {
190        if name.is_empty() {
191            panic!("Identity name cannot be empty!");
192        }
193
194        Self {
195            name,
196            config,
197            public_key,
198            credentials,
199            last_modified: chrono::offset::Utc::now(),
200        }
201    }
202
203    /// Creates the default identity, inferring details from the user's profile
204    pub fn default() -> Result<Self, anyhow::Error> {
205        let config_path = config::global_config_dir().unwrap().join("config.toml");
206        let author: Author = if config_path.exists() {
207            let mut config_file = fs::File::open(&config_path)?;
208            let mut config_text = String::new();
209            config_file.read_to_string(&mut config_text)?;
210
211            let global_config: config::Global = toml::from_str(&config_text)?;
212            global_config.author
213        } else {
214            Author::default()
215        };
216
217        let secret_key = SKey::generate(None);
218        let public_key = secret_key.public_key();
219
220        Ok(Self::new(
221            String::from("default"),
222            Config::from(author),
223            public_key,
224            Some(Credentials::from(secret_key.save(None))),
225        ))
226    }
227
228    /// Returns the secret key, if one exists
229    pub fn secret_key(&self) -> Option<SecretKey> {
230        if let Some(credentials) = &self.credentials {
231            Some(credentials.secret_key.clone())
232        } else {
233            None
234        }
235    }
236
237    /// Strips the identity of any device-specific information, such as key path & identity name
238    /// Returns the stripped identity
239    pub fn as_portable(&self) -> Self {
240        Self {
241            name: String::new(),
242            last_modified: chrono::offset::Utc::now(),
243            config: Config {
244                key_path: None,
245                author: self.config.author.clone(),
246            },
247            public_key: self.public_key.clone(),
248            credentials: None,
249        }
250    }
251
252    /// Decrypts the user's secret key, prompting the user for password if necessary
253    /// Returns a tuple containing the decrypted key & the valid password
254    pub fn decrypt(&self) -> Result<(SKey, Option<String>), anyhow::Error> {
255        self.credentials.clone().unwrap().decrypt(&self.name)
256    }
257
258    fn change_password(&mut self) -> Result<(), anyhow::Error> {
259        let (decryped_key, _) = self.decrypt()?;
260
261        let user_password = Password::new()?
262            .with_prompt("New password")
263            .with_allow_empty(true)
264            .with_confirmation("Confirm password", "Password mismatch")
265            .interact()?;
266
267        let password = if user_password.is_empty() {
268            OnceLock::new()
269        } else {
270            // User has entered a password, add it to the keyring
271            if let Err(e) = keyring::Entry::new("pijul", &self.name)
272                .and_then(|x| x.set_password(&user_password))
273            {
274                warn!("Unable to set password: {e:?}");
275            }
276
277            OnceLock::from(user_password)
278        };
279
280        // Update the key pair to match this new password
281        self.public_key = decryped_key.public_key();
282        self.credentials = Some(Credentials {
283            secret_key: decryped_key.save(password.get().map(String::as_str)),
284            password,
285        });
286
287        Ok(())
288    }
289}
290
291// Implement Display so that the user can select identities from the fuzzy matcher
292impl Display for Complete {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        // Try and jog the user's memory by giving them a bit more context
295        let has_username = !self.config.author.username.is_empty();
296        let has_remote = !self.config.author.origin.is_empty();
297
298        let remote_details: Option<String> = if has_username && has_remote {
299            Some(format!(
300                " [{}@{}]",
301                self.config.author.username, self.config.author.origin
302            ))
303        } else if has_username {
304            Some(format!(" [@{}]", self.config.author.username))
305        } else if has_remote {
306            Some(format!(" [:{}]", self.config.author.origin))
307        } else {
308            None
309        };
310
311        write!(f, "{}{}", self.name, remote_details.unwrap_or_default())
312    }
313}