Skip to main content

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