This is quite a large refactor that completely removes the previous crate::Identity struct in favour of the new crate::identity::Complete, which is almost a drop-in replacement with some extra features. To accomplish this, identity::Complete has been re-worked to support all use-cases, including storage of identities both locally & remotely. Also, some implementations on identity::Complete have been re-worked to allow for greater readability, and the previously removed key_path field has been re-implemented.
4OJWMSOWWNT5N4W4FDMKBZB5UARCLGV3SRZVKGR4EFAYFUMUHM7AC FVQYZQFL7WHSC3UUPJ4IWTP7SKTDQ4K6K5HY4EDK3JKXG3CQNZEAC 4KJ45IJLTIE35KQZUSFMFS67RNENG4P2FZMKMULJLGGYMKJUVRSQC DWSAYGVEOR4D2EKIICEZUWCRGJTUXQQLOUWMYIFV7XN62K44F4FAC LGEJSLTYI7Y2CYC3AN6ECMT3D3MTWCAKZPVQEG5MPM2OBW5FQ46AC SXEYMYF7P4RZMZ46WPL4IZUTSQ2ATBWYZX7QNVMS3SGOYXYOHAGQC A3RM526Y7LUXNYW4TL56YKQ5GVOK2R5D7JJVTSQ6TT5MEXIR6YAAC FBXYP7QM7SG6P2JDJVQPPCRKJE3GVYXNQ5GVV4GRDUNG6Q4ZRDJQC EEBKW7VTILH6AGGV57ZIJ3DJGYHDSYBWGU3C7Q4WWAKSVNUGIYMQC LJFJEX43HDS33O5HCRXH7AR3GTQZDHNWHEQBOERDNPNXR3B3XZ3QC SLJ3OHD4F6GJGZ3SV2D7DMR3PXYHPSI64X77KZ3RJ24EGEX6ZNQAC SMMBFECLGSUKRZW5YPOQPOQCOY2CH2OTZXBSZ3KG2N3J3HQZ5PSAC NAUECZW353R5RHT4GGQJIEZPA5EYRGQYTSP7IJNBJS3CXBSTNJDQC OU6JOR3CDZTH2H3NTGMV3WDIAWPD3VEJI7JRY3VJ7LPDR3QOA52QC #[derive(Debug)]pub(super) struct PotentialPublicKey {pub key: PublicKey,pub generated_by: GeneratedBy,}#[derive(Debug)]pub(super) enum GeneratedBy {SecretKey,PublicKey,Identity,}impl PotentialPublicKey {/// Loads all given public keys into an array.////// # Arguments/// * `public_key` - The public key to validate/// * `identity` - The identity to validate/// * `secret_key` - The secret key to validate/// * `password` - The password used to decrypt the secret key/// * `name` - The name of the identity. This is encoded on-disk as identities/`<NAME>`pub(super) fn load_public_keys(public_key: Option<PublicKey>,identity: Option<Identity>,secret_key: &SecretKey,password: Option<String>,name: &str,) -> Result<Vec<Self>, anyhow::Error> {// Construct a vec of all potential (not yet validated) public keys, and make sure they all matchlet mut public_keys: Vec<Self> = vec![];let user_password = get_valid_password(secret_key, password, name)?;public_keys.push(Self {key: secret_key.load(user_password.as_deref())?.public_key(),generated_by: GeneratedBy::SecretKey,});if let Some(key) = public_key {public_keys.push(Self {key,generated_by: GeneratedBy::PublicKey,});}if let Some(user_identity) = identity {public_keys.push(Self {key: user_identity.public_key,generated_by: GeneratedBy::Identity,});}Ok(public_keys)}}
let public_keys = PotentialPublicKey::load_public_keys(None,Some(self.identity.clone()),&self.secret_key,self.password.clone(),&self.name,)?;if public_keys.len() >= 2 {let mut keys_iter = public_keys.iter();let first_key = keys_iter.next().unwrap();
let public_key = &self.public_key;let decryped_public_key = self.decrypt()?.0.public_key();
let mut different_keys = vec![first_key];for key in keys_iter {if !different_keys.iter().any(|k| k.key == key.key) {different_keys.push(key);}}if different_keys.len() != 1 {let mut stderr = std::io::stderr();writeln!(stderr, "{MISMATCHED_KEYS_MESSAGE}")?;writeln!(stderr, "Got the following public key signatures:")?;for key in different_keys {let source = match key.generated_by {GeneratedBy::Identity => "Identity",GeneratedBy::PublicKey => "Public key",GeneratedBy::SecretKey => "Secret key",};writeln!(stderr, "{} ({source})", key.key.key)?;}
if public_key.key != decryped_public_key.key {let mut stderr = std::io::stderr();writeln!(stderr, "{MISMATCHED_KEYS_MESSAGE}")?;writeln!(stderr, "Got the following public key signatures:")?;writeln!(stderr, "Plaintext public key: {public_key:#?}")?;writeln!(stderr, "Decrypted public key: {decryped_public_key:#?}")?;
password,secret_key,Identity {login: author.login,name: author.full_name,email: author.email,origin,last_modified: chrono::DateTime::timestamp(&chrono::offset::Utc::now()).try_into().context("Failed to create UNIX timestamp")?,public_key,},true,
config,public_key,Some(super::Credentials::from(secret_key)),
#[derive(Clone, Debug, Serialize, Deserialize)]pub struct Config {#[serde(flatten)]pub author: Author,#[serde(default, skip_serializing_if = "Option::is_none")]pub key_path: Option<PathBuf>,}impl Default for Config {fn default() -> Self {Self {key_path: None,author: Author::default(),}}}impl From<Author> for Config {fn from(author: Author) -> Self {Self {key_path: None,author,}}}
pub struct Credentials {secret_key: SecretKey,password: OnceCell<String>,}impl Credentials {pub fn new(secret_key: SecretKey, password: Option<String>) -> Self {Self {secret_key,password: if let Some(pw) = password {OnceCell::from(pw)} else {OnceCell::new()},}}}impl From<SecretKey> for Credentials {fn from(secret_key: SecretKey) -> Self {Self {secret_key,password: OnceCell::new(),}}}impl Credentials {pub fn decrypt(&mut self, name: &str) -> Result<(SKey, Option<String>), anyhow::Error> {if self.secret_key.encryption.is_none() {// Don't mind what the given password is, the secret key has no encryption// Make sure to revoke the passwordself.password.take();Ok((self.secret_key.load(None)?, None))} else if let Ok(key) = self.secret_key.load(self.password.get().map(String::as_str)){// The password matches secret key, no extra work neededOk((key, self.password.get().map(|x| x.to_owned())))} else {// Password does not match secret keylet mut stderr = std::io::stderr();let mut password_attempt = String::new();// Try a password stored in the keychainif let Ok(password) = keyring::Entry::new("pijul", name).get_password() {password_attempt = password;}// Re-prompt as long as the password doesn't workwhile self.secret_key.load(Some(&password_attempt)).is_err() {writeln!(stderr, "Password does not match secret key")?;password_attempt = Password::with_theme(config::load_theme()?.as_ref()).with_prompt("Password for secret key").allow_empty_password(true).interact()?;}// Update the passwordkeyring::Entry::new("pijul", name).set_password(&password_attempt)?;self.password.set(password_attempt.clone()).unwrap();Ok((self.secret_key.load(Some(&password_attempt))?,Some(password_attempt),))}}}#[derive(Clone, Debug, Serialize, Deserialize)]
pub password: Option<String>,pub secret_key: SecretKey,pub identity: Identity,pub link_remote: bool,
#[serde(flatten)]pub config: Config,pub last_modified: chrono::DateTime<chrono::Utc>,pub public_key: PublicKey,#[serde(skip)]credentials: Option<Credentials>,
/// * `password` - Password used to decrypt the secret key/// * `secret_key` - The corresponding secret key/// * `identity` - Information about the author including name, email, login/// * `link_remote` - If this identity should be linked with a remote (e.g. the Nest)pub const fn new(
/// * `config` - User configuration including author details & SSH key/// * `public_key` - The user's public key/// * `credentials` - The user's secret data including secret key & passwordpub fn new(
None,secret_key.save(None),Identity {login: author.login,name: author.full_name,origin: String::from("ssh.pijul.com"),email: author.email,last_modified: chrono::DateTime::timestamp(&chrono::offset::Utc::now()).try_into()?,public_key,},true,
Config::from(author),public_key,Some(Credentials::from(secret_key.save(None))),
}/// Returns the secret key, if one existspub fn secret_key(&self) -> Option<SecretKey> {if let Some(credentials) = &self.credentials {Some(credentials.secret_key.clone())} else {None}}/// Strips the identity of any device-specific information, such as key path & identity name/// Returns the stripped identitypub fn as_portable(&self) -> Self {Self {name: String::new(),last_modified: chrono::offset::Utc::now(),config: Config {key_path: None,author: self.config.author.clone(),},public_key: self.public_key.clone(),credentials: None,}}/// Decrypts the user's secret key, prompting the user for password if necessary/// Returns a tuple containing the decrypted key & the valid passwordpub fn decrypt(&self) -> Result<(SKey, Option<String>), anyhow::Error> {self.credentials.clone().unwrap().decrypt(&self.name)}fn change_password(&mut self) -> Result<(), anyhow::Error> {let (decryped_key, _) = self.decrypt()?;let user_password = Password::with_theme(config::load_theme()?.as_ref()).with_prompt("New password").allow_empty_password(true).with_confirmation("Confirm password", "Password mismatch").interact()?;let password = if user_password.is_empty() {OnceCell::new()} else {// User has entered a password, add it to the keyringkeyring::Entry::new("pijul", &self.name).set_password(&user_password)?;OnceCell::from(user_password)};// Update the key pair to match this new passwordself.public_key = decryped_key.public_key();self.credentials = Some(Credentials {secret_key: decryped_key.save(password.get().map(String::as_str)),password,});Ok(())
}}/// Loads a password, either from the keychain or from user input////// # Arguments/// * `name` - The name of the identity. This is encoded on-disk as identities/`<NAME>`fn load_password(name: &str) -> Result<Option<String>, anyhow::Error> {if let Ok(password) = keyring::Entry::new("pijul", name).get_password() {if password.is_empty() {return Ok(None);}return Ok(Some(password));}let pw = Password::with_theme(config::load_theme()?.as_ref()).with_prompt("Password for secret key").allow_empty_password(true).interact()?;if pw.is_empty() {Ok(None)} else {Ok(Some(pw))
/// Validates a given password, prompting for user input if needed.////// # Arguments/// * `secret_key` - The secret key to decrypt/// * `password` - The password to validate/// * `name` - The name of the identity. This is encoded on-disk as identities/`<NAME>`pub fn get_valid_password(secret_key: &SecretKey,password: Option<String>,name: &str,) -> Result<Option<String>, anyhow::Error> {if secret_key.encryption.is_some() || secret_key.load(None).is_err() {if secret_key.load(password.as_deref()).is_ok() {Ok(password)} else {let mut user_password = load_password(name)?;// If the user enters the wrong password, repromptlet mut stderr = std::io::stderr();while secret_key.load(user_password.as_deref()).is_err() {writeln!(stderr, "Incorrect password! Please try again.")?;user_password = load_password(name)?;}Ok(user_password)}} else {Ok(None)}}
/// Decrypts the secret key associated with the given identity name. If the given password is invalid, the user may be prompted.////// # Arguments/// * `identity_name` - The name of the identity. This is encoded on-disk as identities/`<NAME>`/// * `password` - The password used to attempt decryptionpub fn decrypt_secret_key(identity_name: &str,password: Option<String>,) -> Result<(libpijul::key::SecretKey, libpijul::key::SKey), anyhow::Error> {let secret_key = secret_key(identity_name)?;let password = get_valid_password(&secret_key, password, identity_name)?;let decrypted_key = secret_key.load(password.as_deref())?;Ok((secret_key, decrypted_key))}
let email: Option<String> = if user_email.is_empty() {None} else {Some(user_email)};
if Confirm::with_theme(config.as_ref()).with_prompt(&format!("Would you like to change the default SSH key? (Current key: {})",if let Some(path) = &self.config.key_path {format!("{path:#?}")} else {String::from("none")})).default(false).interact()?{new_identity.prompt_ssh().await?;}if Confirm::with_theme(config.as_ref()).with_prompt(&format!("Do you want to change the encryption? (Current status: {})",self.credentials.clone().unwrap().secret_key.encryption.map_or("not encrypted", |_| "encrypted"))).default(false).interact()?{new_identity.change_password()?;}// Update the expiry AFTER potential secret key resetnew_identity.prompt_expiry()?;if Confirm::with_theme(config.as_ref()).with_prompt("Do you want to link this identity to a remote?").default(true).interact()?{new_identity.prompt_remote(link_remote).await?;}new_identity.last_modified = chrono::offset::Utc::now();Ok(new_identity)}async fn prompt_ssh(&mut self) -> Result<(), anyhow::Error> {let mut ssh_agent = thrussh_keys::agent::client::AgentClient::connect_env().await?;let identities = ssh_agent.request_identities().await?;let ssh_dir = dirs_next::home_dir().unwrap().join(".ssh");let selection = FuzzySelect::with_theme(config::load_theme()?.as_ref()).with_prompt("Select key").items(&identities.iter().map(|id| {format!("{}: {} ({})",id.name(),id.fingerprint(),ssh_dir.join(match id {PublicKey::Ed25519(_) =>thrussh_keys::key::ED25519.identity_file(),PublicKey::RSA { ref hash, .. } => hash.name().identity_file(),}).display(),)}).collect::<Vec<_>>(),).default(0).interact()?;self.config.key_path = Some(ssh_dir.join(match identities[selection] {PublicKey::Ed25519(_) => thrussh_keys::key::ED25519.identity_file(),PublicKey::RSA { ref hash, .. } => hash.name().identity_file(),}));Ok(())}async fn prompt_remote(&mut self, link_remote: bool) -> Result<(), IdentityCreateError> {let config = config::load_theme()?;self.config.author.user_name = Input::with_theme(config.as_ref()).with_prompt("Remote username").default(whoami::username()).with_initial_text(&self.config.author.user_name).interact_text().unwrap();self.config.author.origin = Input::with_theme(config.as_ref()).with_prompt("Remote URL").with_initial_text(&self.config.author.origin).default(String::from("ssh.pijul.com")).interact_text().unwrap();// Prove the identity to the serverif link_remote&& self.prove(*NO_CERT_CHECK.get_or_init(|| false)).await.is_err(){return Err(IdentityCreateError::ProveFailed(self.name.clone()));}Ok(())}
};let link_remote: bool = Confirm::with_theme(config.as_ref()).with_prompt("Do you want to link this identity to a remote?").default(self.link_remote).interact()?;let user_name = if link_remote {Input::with_theme(config.as_ref()).with_prompt("Remote username").default(whoami::username()).with_initial_text(self.identity.login).interact_text()?} else {String::new()};let remote_url = if link_remote {Input::with_theme(config.as_ref()).with_prompt("Remote URL").with_initial_text(if replace_current {self.identity.origin} else {String::new()}).default(String::from("ssh.pijul.com")).interact_text()?} else {String::new()
let change_password = Confirm::with_theme(config.as_ref()).with_prompt(&format!("Do you want to change the encryption? (Current status: {})",self.secret_key.encryption.clone().map_or("not encrypted", |_| "encrypted"))).default(false).interact()?;let password = if change_password {let user_password = Password::with_theme(config.as_ref()).with_prompt("Secret key password").allow_empty_password(true).with_confirmation("Confirm password", "Password mismatch").interact()?;
let pass = if user_password.is_empty() {None} else {Some(user_password)};// Update the key pair to match this new passwordlet loaded_key = self.secret_key.clone().load(get_valid_password(&self.secret_key, self.password, &identity_name)?.as_deref(),)?;self.identity.public_key = loaded_key.public_key();self.secret_key = loaded_key.save(pass.as_deref());pass} else {self.password};// Update the expiry AFTER potential secret key resetself.identity.public_key.expires = expiry;Ok(Self::new(identity_name,password,self.secret_key,Identity {public_key: self.identity.public_key,login: user_name,name: Some(full_name),origin: remote_url,email,last_modified: chrono::DateTime::timestamp(&chrono::offset::Utc::now()).try_into()?,},link_remote,))
Ok(())
// Only exchange keys with the server if the user agreed to do soif link_remote&& confirmed_identity.link_remote&& confirmed_identity.prove(*NO_CERT_CHECK.get_or_init(|| false)).await.is_err(){return Err(IdentityCreateError::ProveFailed(confirmed_identity.name.clone(),));}// If the user has entered a password, add it to the keyringif let Some(password) = confirmed_identity.password.clone() {Entry::new("pijul", &confirmed_identity.name).set_password(&password).context("Unable to write to keychain")?;}Ok(confirmed_identity)
Ok(())
// This is for backwards compatability with older versions#[serde(alias = "name")]pub login: String,pub email: Option<String>,pub full_name: Option<String>,
// Older versions called this 'name', but 'user_name' is more descriptive#[serde(alias = "name", default, skip_serializing_if = "String::is_empty")]pub user_name: String,#[serde(alias = "full_name", default, skip_serializing_if = "String::is_empty")]pub display_name: String,#[serde(default, skip_serializing_if = "String::is_empty")]pub email: String,#[serde(default, skip_serializing_if = "String::is_empty")]pub origin: String,// This has been moved to identity::Config, but we should still be able to read the values#[serde(default, skip_serializing)]pub key_path: Option<PathBuf>,}impl Default for Author {fn default() -> Self {Self {user_name: String::new(),email: String::new(),display_name: whoami::realname(),origin: String::new(),key_path: None,}}
}use serde_derive::*;#[derive(Clone, Debug, Serialize, Deserialize)]pub struct Identity {pub login: String,#[serde(default, skip_serializing_if = "Option::is_none")]pub name: Option<String>,#[serde(default, skip_serializing_if = "String::is_empty")]pub origin: String,#[serde(default, skip_serializing_if = "Option::is_none")]pub email: Option<String>,#[serde(default)]pub last_modified: u64,pub public_key: libpijul::key::PublicKey,
if let Some(ref name) = id.name {if let Some(ref email) = id.email {e.insert(format!("{} ({}) <{}>", name, id.login, email))
if id.config.author.display_name.is_empty() {e.insert(id.config.author.user_name)} else {if id.config.author.email.is_empty() {e.insert(format!("{} ({})",id.config.author.display_name,id.config.author.user_name))
Ok(Complete {name: identity_name.unwrap_or(default.name),password: pw.clone(),secret_key: secret_key.save(pw.as_deref()),identity: Identity {login: login.unwrap_or(default.identity.login),name: display_name.or(default.identity.name),origin: origin.unwrap_or(default.identity.origin),email: email.or(default.identity.email),last_modified: default.identity.last_modified,public_key: secret_key.public_key(),
Ok(Complete::new(identity_name.unwrap_or(default.name),identity::Config {key_path: None,author: Author {user_name: login.unwrap_or(default.config.author.user_name),display_name: display_name.unwrap_or(default.config.author.display_name),email: email.unwrap_or(default.config.author.email),origin: origin.unwrap_or(default.config.author.origin),key_path: None,},
tree.add_empty_child(format!("Key: {}", identity.identity.public_key.key));tree.add_empty_child(format!("Version: {}",identity.identity.public_key.version));
tree.add_empty_child(format!("Key: {}", identity.public_key.key));tree.add_empty_child(format!("Version: {}", identity.public_key.version));
// User has changed credentials, so attempt to re-proveif old_identity.identity.login != new_identity.identity.login|| old_identity.identity.origin != new_identity.identity.origin{if !options.no_link && new_identity.link_remote {new_identity.prove(self.no_cert_check).await?;}}
old_identity.replace_with(new_identity)?;