Implement new identity management

finchie
Aug 16, 2022, 8:53 AM
4KJ45IJLTIE35KQZUSFMFS67RNENG4P2FZMKMULJLGGYMKJUVRSQC

Dependencies

  • [2] ZSFJT4SF Allow remotes to have a different push and pull address
  • [3] CXSCA5HN Fixing a counter-intuitive error when a local repo does not exist
  • [4] NAUECZW3 Fixing the map between keys and identities
  • [5] OFQY3GUU Formatting and versions
  • [6] 6ZHY3XTG Updating deprecated methods in clap
  • [7] X243Z3Y5 Recording only the required metadata (can even be changed later!)
  • [8] EEBKW7VT Keys and identities
  • [9] SXEYMYF7 Fixing the bad changes in history (unfortunately, by rebooting).
  • [10] QL6K2ZM3 Tags
  • [11] OKE6SXPP improve docs, feedback for pijul key
  • [12] UBCBQ5FG Removing pijul/src/commands/checkout.rs (unused file), as well as litorg comments
  • [13] QQZNSB26 Permission update (after #X243)
  • [14] G65S7FAW Version bump and cleanup
  • [15] SMMBFECL Converting to the new patch format "online"
  • [16] HDGRZISM Version updates
  • [17] JL4WKA5P Implement the Sanakirja concurrency model in a cross-process way
  • [18] YTQS4ES3 Fixing a parsing problem (related to permissions), and the associated permissions
  • [19] A3RM526Y Integrating identity malleability
  • [20] PIQCNEEB Upgrading to Clap 3.0.0-alpha.5

Change contents

  • file addition: identity (d--r------)
    [7.21414]
  • file addition: repair.rs (----------)
    [0.20]
    use super::create::IdentityCreateError;
    use super::{get_valid_password, Complete};
    use crate::commands::Identity;
    use crate::config;
    use libpijul::key::{PublicKey, SecretKey};
    use std::convert::TryInto;
    use std::fs;
    use std::io::{Read, Write};
    use std::path::PathBuf;
    use anyhow::{bail, Context};
    use log::debug;
    use thiserror::Error;
    const FIRST_IDENTITY_MESSAGE: &str = "It doesn't look like you have any identities configured!
    Each author in Pijul is identified by a unique key to provide greater security & flexibility over names/emails.
    To make sure humans (including you!) can easily identify these keys, we need a few personal details.
    For more information see https://pijul.org/manual/keys.html";
    const MIGRATE_IDENTITY_MESSAGE: &str =
    "It seems you have configured an identity in an older version of Pijul, which uses an older identity format!
    Please take a moment to confirm your details are correct.";
    const MISMATCHED_KEYS_MESSAGE: &str = "It seems the keys on your system are mismatched!
    This is most likely the result of data corruption, please check your drive and try again.";
    #[derive(Error, Debug)]
    pub enum IdentityParseError {
    #[error("Mismatching keys")]
    MismatchingKeys,
    #[error("Could not find secret key at path {0}")]
    NoSecretKey(PathBuf),
    #[error(transparent)]
    Other(#[from] anyhow::Error),
    }
    #[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 match
    let 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)
    }
    }
    /// Ensure that the user has at least one valid identity on disk.
    ///
    /// This function performs the following:
    /// * Migrate users from the old identity format
    /// * Validate all identity key pairs
    /// * Create a new identity if none exist
    ///
    /// # Arguments
    /// * `no_prompt` - If the user should not be prompted for input.
    pub async fn fix_identities(no_prompt: bool) -> Result<(), anyhow::Error> {
    let mut dir = config::global_config_dir().unwrap();
    dir.push("identities");
    std::fs::create_dir_all(&dir)?;
    dir.pop();
    let identities = Complete::load_all()?;
    if identities.is_empty() {
    // This could be because the old format exists on disk, but if the
    // extraction fails then we can be fairly sure the user simply isn't set up
    let extraction_result = Complete::from_old_format(None);
    let mut stderr = std::io::stderr();
    match extraction_result {
    Ok(old_identity) => {
    // Migrate to new format
    writeln!(stderr, "{MIGRATE_IDENTITY_MESSAGE}")?;
    // Confirm details then write to disk
    // match create_identity(Some(old_identity.clone()), no_prompt).await {
    if let Err(e) = old_identity.clone().create(no_prompt, true).await {
    match e {
    IdentityCreateError::ProveFailed(name) => writeln!(stderr, "Failed to prove identity. You will still be able to create & sign patches, but until you run `pijul identity prove --name {name}` they will not be linked to your personal details. If you are on an enterprise network, perhaps try running with `--no-cert-check`")?,
    IdentityCreateError::Other(err) => return Err(err),
    }
    };
    // The identity is stored as the public key's signature on disk
    let identity_path = format!("identities/{}", &old_identity.identity.public_key.key);
    // Try to delete what remains of the old identities
    let paths_to_delete =
    vec!["publickey.json", "secretkey.json", identity_path.as_str()];
    for path in paths_to_delete {
    let file_path = dir.join(path);
    if file_path.exists() {
    debug!("Deleting old file: {file_path:?}");
    fs::remove_file(file_path)?;
    } else {
    debug!("Could not delete old file (path not found): {file_path:?}");
    }
    }
    }
    Err(e) => {
    match e {
    IdentityParseError::MismatchingKeys => {
    bail!("User must repair broken keys before continuing");
    }
    IdentityParseError::NoSecretKey(_) => {
    // This is the user's first time setting up an identity
    if no_prompt {
    bail!("No identities configured");
    }
    writeln!(stderr, "{FIRST_IDENTITY_MESSAGE}")?;
    Complete::default()?.create(no_prompt, true).await?;
    }
    IdentityParseError::Other(err) => {
    bail!(err);
    }
    }
    }
    }
    }
    // Sanity check to make sure everything is in order
    for identity in Complete::load_all()? {
    identity.valid_keys()?;
    }
    Ok(())
    }
    impl Complete {
    /// Checks if the key pair on disk is valid
    fn valid_keys(&self) -> Result<bool, anyhow::Error> {
    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 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)?;
    }
    return Ok(false);
    }
    }
    Ok(true)
    }
    /// Migrate user from old to new identity format.
    ///
    /// # Arguments
    /// * `password` - The password used to decrypt the secret key
    ///
    /// # Data format
    /// Data stored in the old format should look as follows:
    /// ```md
    /// .config/pijul/ (or applicable global config directory)
    /// ├── config.toml
    /// │ ├── Username
    /// │ ├── Full name
    /// │ └── Email
    /// ├── secretkey.json
    /// │ ├── Version
    /// │ ├── Algorithm
    /// │ └── Key
    /// ├── publickey.json
    /// │ ├── Version
    /// │ ├── Algorithm
    /// │ ├── Signature
    /// │ └── Key
    /// └── identities/
    /// └── <PUBLIC KEY> (JSON, no extension)
    /// ├── Public key
    /// │ ├── Version
    /// │ ├── Algorithm
    /// │ ├── Signature
    /// │ └── Key
    /// ├── Login
    /// └── Last modified
    ///```
    ///
    /// As you can see, there is a lot of redundant data. We can leverage this
    /// information to repair partially corrupted state. For example, we can
    /// reconstruct `publickey.json` using the identity file. We are also able
    /// to reconstruct the public key from the private key, so the steps should
    /// look roughly as follows:
    /// 1. Extract secret key
    /// 2. Extract public key from (in order):
    /// 1. publickey.json
    /// 2. File in identities/
    /// 3. secretkey.json
    /// 3. Extract login info from (in order):
    /// 1. File in identities/
    /// 2. config.toml
    /// 4. Validate extracted data (query user to fill in blanks)
    fn from_old_format(mut password: Option<String>) -> Result<Self, IdentityParseError> {
    let config_dir = config::global_config_dir().unwrap();
    let config_path = config_dir.join("config.toml");
    let identities_path = config_dir.join("identities");
    let public_key_path = config_dir.join("publickey.json");
    let secret_key_path = config_dir.join("secretkey.json");
    // If we don't have the private key, there is no chance of repairing
    // the data. This will also trigger if the data is not in the old format
    if !secret_key_path.exists() {
    return Err(IdentityParseError::NoSecretKey(secret_key_path));
    }
    // From this point, we can be in 2 states:
    // - Old identity format
    // - Broken/missing data
    // Extract data from secretkey.json
    let mut secret_key_file =
    fs::File::open(&secret_key_path).context("Failed to open secret key file")?;
    let mut secret_key_text = String::new();
    secret_key_file
    .read_to_string(&mut secret_key_text)
    .context("Failed to read secret key file")?;
    let secret_key: SecretKey =
    serde_json::from_str(&secret_key_text).context("Failed to parse secret key file")?;
    // Extract data from publickey.json
    // TODO: handle public key not existing
    let public_key: PublicKey = if public_key_path.exists() {
    let mut public_key_file =
    fs::File::open(&public_key_path).context("Failed to open public key file")?;
    let mut public_key_text = String::new();
    public_key_file
    .read_to_string(&mut public_key_text)
    .context("Failed to read public key file")?;
    serde_json::from_str(&public_key_text).context("Failed to parse public key file")?
    } else {
    return Err(IdentityParseError::Other(anyhow::anyhow!(
    "Public key does not exist!"
    )));
    };
    // Extract valid identities
    let identity: Option<Identity> = if identities_path.exists() {
    if identities_path.is_dir() {
    let identities_iter =
    fs::read_dir(identities_path).context("Failed to read identities directory")?;
    let mut identities: Vec<Identity> = vec![];
    // We only need to keep the valid files
    for dir_entry in identities_iter {
    let path = dir_entry.unwrap().path();
    if path.is_file() {
    // Try and deserialize the data. If it fails, there is
    // a fairly high chance it's not what we need
    let mut identity_file =
    fs::File::open(&path).context("Failed to open identity file")?;
    let mut identity_text = String::new();
    identity_file
    .read_to_string(&mut identity_text)
    .context("Failed to read identity file")?;
    let deserialization_result: Result<Identity, _> =
    serde_json::from_str(&identity_text);
    if deserialization_result.is_ok() {
    identities.push(
    deserialization_result
    .context("Failed to deserialize identity file")?,
    );
    }
    }
    }
    if identities.len() == 1 {
    Some(identities[0].clone())
    } else {
    None
    }
    } else {
    None
    }
    } else {
    None
    };
    let author: config::Author = if config_path.exists() {
    let mut config_file =
    fs::File::open(&config_path).context("Failed to open config file")?;
    let mut config_text = String::new();
    config_file
    .read_to_string(&mut config_text)
    .context("Failed to read config file")?;
    let config_data: config::Global =
    toml::from_str(&config_text).context("Failed to parse config file")?;
    config_data.author
    } else {
    let login = identity
    .as_ref()
    .map_or_else(String::new, |x| x.login.clone());
    config::Author {
    login,
    email: None,
    full_name: None,
    }
    };
    let origin = if let Some(id) = identity {
    id.origin
    } else {
    String::new()
    };
    // If the secret is encrypted, prompt for password
    password = get_valid_password(&secret_key, password.clone(), "")?;
    let identity = Self::new(
    String::from("default"),
    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,
    );
    if identity.valid_keys()? {
    Ok(identity)
    } else {
    Err(IdentityParseError::MismatchingKeys)
    }
    }
    }
  • file addition: mod.rs (----------)
    [0.20]
    //! Complete identity management.
    //!
    //! Pijul uses keys, rather than personal details such as names or emails to attribute changes.
    //! The user can have multiple identities on disk, each with completely unique details. For more
    //! information see [the manual](https://pijul.com/manual/keys.html).
    //!
    //! This module implements various functionality useful for managing identities on disk.
    //! The current format for storing identities is as follows:
    //! ```md
    //! .config/pijul/ (or applicable global config directory)
    //! ├── config.toml (global defaults)
    //! │ ├── Username
    //! │ ├── Full name
    //! │ └── Email
    //! └── identities/
    //! └── <IDENTITY NAME>/
    //! ├── identity.toml
    //! │ ├── Username
    //! │ ├── Full name
    //! │ ├── Email
    //! │ └── Public key
    //! │ ├── Version
    //! │ ├── Algorithm
    //! │ ├── Key
    //! │ └── Signature
    //! └── secret_key.json
    //! ├── Version
    //! ├── Algorithm
    //! └── Key
    //! ```
    #![deny(clippy::all)]
    #![warn(clippy::pedantic)]
    #![warn(clippy::nursery)]
    #![warn(clippy::cargo)]
    mod create;
    mod load;
    mod repair;
    pub use create::NO_CERT_CHECK;
    pub use load::{choose_identity_name, decrypt_secret_key, public_key};
    pub use repair::fix_identities;
    use crate::config;
    use crate::Identity;
    use libpijul::key::{SKey, SecretKey};
    use std::convert::TryInto;
    use std::fmt::Display;
    use std::fs;
    use std::io::{Read, Write};
    use dialoguer::Password;
    #[derive(Clone, Debug)]
    /// A complete user identity, representing the secret key, public key, and user info
    pub struct Complete {
    pub name: String,
    pub password: Option<String>,
    pub secret_key: SecretKey,
    pub identity: Identity,
    pub link_remote: bool,
    }
    impl Complete {
    /// Creates a new identity
    ///
    /// # Arguments
    /// * `name` - The name of the identity. This is encoded on-disk as identities/`<NAME>`
    /// * `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(
    name: String,
    password: Option<String>,
    secret_key: SecretKey,
    identity: Identity,
    link_remote: bool,
    ) -> Self {
    Self {
    name,
    password,
    secret_key,
    identity,
    link_remote,
    }
    }
    /// Creates the default identity, inferring details from the user's profile
    pub fn default() -> Result<Self, anyhow::Error> {
    let config_path = config::global_config_dir().unwrap().join("config.toml");
    let author: config::Author = if config_path.exists() {
    let mut config_file = fs::File::open(&config_path)?;
    let mut config_text = String::new();
    config_file.read_to_string(&mut config_text)?;
    let global_config: config::Global = toml::from_str(&config_text)?;
    global_config.author
    } else {
    config::Author {
    login: String::new(),
    email: None,
    full_name: Some(whoami::realname()),
    }
    };
    let secret_key = SKey::generate(None);
    let public_key = secret_key.public_key();
    Ok(Self::new(
    String::from("default"),
    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,
    ))
    }
    }
    // Implement Display so that the user can select identities from the fuzzy matcher
    impl Display for Complete {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    // Try and jog the user's memory by giving them a bit more context
    let has_username = !self.identity.login.is_empty();
    let has_remote = !self.identity.origin.is_empty();
    let remote_details: Option<String> = if has_username && has_remote {
    Some(format!(
    " [{}@{}]",
    self.identity.login, self.identity.origin
    ))
    } else if has_username {
    Some(format!(" [@{}]", self.identity.login))
    } else if has_remote {
    Some(format!(" [:{}]", self.identity.origin))
    } else {
    None
    };
    write!(f, "{}{}", self.name, remote_details.unwrap_or_default())
    }
    }
    /// 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, reprompt
    let 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)
    }
    }
  • file addition: load.rs (----------)
    [0.20]
    use super::fix_identities;
    use super::get_valid_password;
    use super::Complete;
    use crate::config;
    use crate::Identity;
    use libpijul::key::{PublicKey, SecretKey};
    use std::fs;
    use std::path::Path;
    use std::path::PathBuf;
    use anyhow::bail;
    use dialoguer::FuzzySelect;
    use once_cell::sync::OnceCell;
    static CHOSEN_IDENTITY: OnceCell<String> = OnceCell::new();
    /// Returns the directory in which identity information should be stored.
    ///
    /// # Arguments
    /// * `name` - The name of the identity. This is encoded on-disk as identities/`<NAME>`
    /// * `should_exist` - If the path should already exist.
    ///
    /// # Errors
    /// * An identity of `name` does not exist
    /// * The identity name is empty
    pub fn path(name: &str, should_exist: bool) -> Result<PathBuf, anyhow::Error> {
    let mut path = config::global_config_dir()
    .expect("Could not find global config directory")
    .join("identities");
    if name.is_empty() {
    bail!("Cannot get path of un-named identity");
    }
    path.push(name);
    if !path.exists() && should_exist {
    bail!("Cannot get identity path: name does not exist")
    }
    Ok(path)
    }
    /// Returns the public key for identity named <NAME>.
    ///
    /// # Arguments
    /// * `name` - The name of the identity. This is encoded on-disk as identities/`<NAME>`
    pub fn public_key(name: &str) -> Result<PublicKey, anyhow::Error> {
    let text = fs::read_to_string(path(name, true)?.join("identity.toml"))?;
    let identity: Identity = toml::from_str(&text)?;
    Ok(identity.public_key)
    }
    /// Returns the secret key for identity named <NAME>.
    ///
    /// # Arguments
    /// * `name` - The name of the identity. This is encoded on-disk as identities/`<NAME>`
    pub fn secret_key(name: &str) -> Result<SecretKey, anyhow::Error> {
    let identity_text = fs::read_to_string(path(name, true)?.join("secret_key.json"))?;
    let secret_key: SecretKey = serde_json::from_str(&identity_text)?;
    Ok(secret_key)
    }
    /// Choose an identity, either through defaults or a user prompt.
    ///
    /// # Arguments
    /// * `no_prompt` - If the user should not be prompted for input.
    ///
    /// # Errors
    /// * User input is required to continue, but `no_prompt` is set to true
    pub async fn choose_identity_name(no_prompt: bool) -> Result<String, anyhow::Error> {
    if let Some(name) = CHOSEN_IDENTITY.get() {
    return Ok(name.clone());
    }
    let mut possible_identities = Complete::load_all()?;
    if possible_identities.is_empty() {
    fix_identities(no_prompt).await?;
    possible_identities = Complete::load_all()?;
    }
    let chosen_name = if possible_identities.len() == 1 {
    possible_identities[0].clone().name
    } else if no_prompt {
    bail!("Cannot prompt user to choose identity (--no-prompt is set)");
    } else {
    let index = FuzzySelect::with_theme(config::load_theme()?.as_ref())
    .with_prompt("Select identity")
    .items(&possible_identities)
    .default(0)
    .interact()?;
    possible_identities[index].clone().name
    };
    // The user has selected once, don't want to query them again
    CHOSEN_IDENTITY
    .set(chosen_name.clone())
    .expect("Could not set chosen identity");
    Ok(chosen_name)
    }
    #[cfg(unix)]
    pub fn open_secret_file(path: &Path) -> Result<std::fs::File, std::io::Error> {
    use std::fs::OpenOptions;
    use std::os::unix::fs::OpenOptionsExt;
    OpenOptions::new()
    .write(true)
    .create(true)
    .mode(0o600)
    .open(path)
    }
    #[cfg(not(unix))]
    pub fn open_secret_file(path: &Path) -> Result<std::fs::File, std::io::Error> {
    std::fs::File::create(path)
    }
    /// 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 decryption
    pub 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))
    }
    impl Complete {
    /// Loads a complete identity associated with the given identity name.
    ///
    /// # Arguments
    /// * `identity_name` - The name of the identity. This is encoded on-disk as identities/`<NAME>`
    pub fn load(identity_name: &str) -> Result<Self, anyhow::Error> {
    let identity_path = path(identity_name, true)?;
    let text = fs::read_to_string(identity_path.join("identity.toml"))?;
    let identity: Identity = toml::from_str(&text)?;
    let secret_key = secret_key(identity_name)?;
    Ok(Self::new(
    identity_name.to_string(),
    None,
    secret_key,
    identity,
    true,
    ))
    }
    /// Loads all valid identities found on disk
    pub fn load_all() -> Result<Vec<Self>, anyhow::Error> {
    let config_dir = config::global_config_dir().unwrap();
    let identities_path = config_dir.join("identities");
    std::fs::create_dir_all(&identities_path)?;
    let identities_dir = identities_path.as_path().read_dir()?;
    let mut identities = vec![];
    for dir_entry in identities_dir {
    let file_name = dir_entry?.file_name();
    let identity_name = file_name.to_str().unwrap();
    if let Ok(identity) = Self::load(identity_name) {
    identities.push(identity);
    }
    }
    Ok(identities)
    }
    }
  • file addition: create.rs (----------)
    [0.20]
    use super::load::path;
    use super::{get_valid_password, Complete};
    use crate::config;
    use crate::config::Direction;
    use crate::repository::Repository;
    use crate::Identity;
    use std::io::Write;
    use std::{convert::TryInto, fs};
    use anyhow::{bail, Context};
    use dialoguer::{Confirm, Input, Password};
    use keyring::Entry;
    use log::{debug, warn};
    use once_cell::sync::OnceCell;
    use thiserror::Error;
    pub static NO_CERT_CHECK: OnceCell<bool> = OnceCell::new();
    #[derive(Error, Debug)]
    pub enum IdentityCreateError {
    #[error("Could not prove identity {0}. Please check your credentials & network connection. If you are on an enterprise network, perhaps try running with `--no-cert-check`")]
    ProveFailed(String),
    #[error(transparent)]
    Other(#[from] anyhow::Error),
    }
    impl Complete {
    /// Prompt the user to make changes to an identity.
    ///
    /// # Arguments
    /// * `replace_current` - If the new identity will replace an existing one
    pub fn prompt_changes(mut self, replace_current: bool) -> Result<Self, anyhow::Error> {
    let config = config::load_theme()?;
    let identity_name: String = Input::with_theme(config.as_ref())
    .with_prompt("Unique identity name")
    .default(String::from("default"))
    .allow_empty(false)
    .with_initial_text(if replace_current {
    self.name
    } else {
    String::new()
    })
    .validate_with(move |input: &String| -> Result<(), String> {
    match Self::load(input) {
    Ok(existing_identity) => {
    if replace_current {
    // The user is trying to edit an existing identity
    Ok(())
    } else {
    Err(format!("That identity already exists: {existing_identity}",))
    }
    }
    Err(_) => Ok(()),
    }
    })
    .interact_text()?;
    let full_name: String = Input::with_theme(config.as_ref())
    .with_prompt("Display name")
    .allow_empty(true)
    .default(whoami::realname())
    .with_initial_text(if replace_current {
    self.identity.name.unwrap_or_default()
    } else {
    String::new()
    })
    .interact_text()?;
    let user_email: String = Input::with_theme(config.as_ref())
    .with_prompt("Email (leave blank for none)")
    .allow_empty(true)
    .with_initial_text(self.identity.email.unwrap_or_default())
    .validate_with(move |input: &String| -> Result<(), &str> {
    if input.is_empty() || validator::validate_email(input) {
    Ok(())
    } else {
    Err("Invalid email address")
    }
    })
    .interact_text()?;
    let email: Option<String> = if user_email.is_empty() {
    None
    } else {
    Some(user_email)
    };
    let expiry_message = self
    .identity
    .public_key
    .expires
    .map(|date| date.format("%Y-%m-%d %H:%M:%S").to_string());
    let expiry = if Confirm::with_theme(config.as_ref())
    .with_prompt(format!(
    "Do you want this key to expire? (Current expiry: {})",
    expiry_message
    .clone()
    .unwrap_or_else(|| String::from("never"))
    ))
    .default(false)
    .interact()?
    {
    let time_stamp: String = Input::with_theme(config.as_ref())
    .with_prompt("Expiry date (YYYY-MM-DD)")
    .with_initial_text(expiry_message.unwrap_or_default())
    .validate_with(move |input: &String| -> Result<(), &str> {
    let parsed_date = dateparser::parse_with_timezone(input, &chrono::offset::Utc);
    if parsed_date.is_err() {
    return Err("Invalid date");
    }
    let date = parsed_date.unwrap();
    if chrono::offset::Utc::now().timestamp_millis() > date.timestamp_millis() {
    Err("Date is in the past")
    } else {
    Ok(())
    }
    })
    .interact_text()?;
    Some(dateparser::parse_with_timezone(
    &time_stamp,
    &chrono::offset::Utc,
    )?)
    } else {
    None
    };
    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 password
    let 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 reset
    self.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,
    ))
    }
    /// Write an identity to disk.
    ///
    /// # Arguments
    /// * `replace_current` - If the new identity will replace an existing one
    fn write(&self, replace_current: bool) -> Result<(), anyhow::Error> {
    if let Ok(existing_identity) = Self::load(&self.name) {
    if !replace_current {
    bail!("An identity with that name already exists: {existing_identity}");
    }
    }
    // Write the relevant identity files
    let identity_dir = path(&self.name, false)?;
    std::fs::create_dir_all(&identity_dir)?;
    let config_data = toml::to_string_pretty(&self.identity)?;
    let mut config_file = std::fs::File::create(identity_dir.join("identity.toml"))?;
    config_file.write_all(config_data.as_bytes())?;
    let key_data = serde_json::to_string_pretty(&self.secret_key)?;
    let mut key_file = super::load::open_secret_file(&identity_dir.join("secret_key.json"))?;
    key_file.write_all(key_data.as_bytes())?;
    Ok(())
    }
    /// Associate a generated key with a remote identity. Patches authored
    /// by unproven keys will only display the key as the author.
    pub async fn prove(&self, no_cert_check: bool) -> Result<(), anyhow::Error> {
    let mut stderr = std::io::stderr();
    writeln!(
    stderr,
    "Linking identity {}@{}",
    &self.identity.login, &self.identity.origin
    )?;
    let remote = self.identity.origin.clone();
    let mut remote = if let Ok(repo) = Repository::find_root(None) {
    use crate::remote::RemoteRepo;
    if let RemoteRepo::Ssh(ssh) = repo
    .remote(
    None,
    &remote,
    crate::DEFAULT_CHANNEL,
    Direction::Pull,
    no_cert_check,
    false,
    )
    .await?
    {
    ssh
    } else {
    bail!("No such remote: {}", remote)
    }
    } else if let Some(mut ssh) = crate::remote::ssh::ssh_remote(&remote, false) {
    if let Some(c) = ssh.connect(&remote, crate::DEFAULT_CHANNEL).await? {
    c
    } else {
    bail!("No such remote: {}", remote)
    }
    } else {
    bail!("No such remote: {}", remote)
    };
    let (_, key) = super::load::decrypt_secret_key(&self.name, None)?;
    remote.prove(key).await?;
    Ok(())
    }
    /// Create a complete identity, including writing to disk & exchanging key with origin.
    ///
    /// # Arguments
    /// * `no_prompt` - If the user should not be prompted for input.
    /// * `link_remote` - Override if the identity should be exchanged with the remote.
    pub async fn create(
    self,
    no_prompt: bool,
    link_remote: bool,
    ) -> Result<Self, IdentityCreateError> {
    let confirmed_identity = if no_prompt {
    self
    } else {
    self.prompt_changes(false)?
    };
    confirmed_identity.write(false)?;
    // Only exchange keys with the server if the user agreed to do so
    if 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 keyring
    if 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)
    }
    /// Replace an existing identity with a new one.
    ///
    /// # Arguments
    /// * `new_identity` - The new identity that will be created
    pub fn replace_with(self, new_identity: Self) -> Result<Self, anyhow::Error> {
    // Remove the old data
    let old_identity_path = path(&self.name, true)?;
    debug!("Removing old directory: {old_identity_path:?}");
    fs::remove_dir_all(old_identity_path).context("Could not remove old identity.")?;
    // Write the new data
    new_identity.write(true)?;
    // Remove the old keychain entry if name has changed
    if self.name != new_identity.name {
    if let Err(e) = Entry::new("pijul", &self.name).delete_password() {
    warn!("Unable to delete password: {e:?}");
    }
    }
    // Update the password
    if let Some(password) = new_identity.password.clone() {
    Entry::new("pijul", &new_identity.name).set_password(&password)?;
    } else if let Err(e) = Entry::new("pijul", &new_identity.name).delete_password() {
    warn!("Unable to delete password: {e:?}");
    }
    Ok(new_identity)
    }
    }
  • file deletion: key.rs (---r------)key.rs (----------)
    [7.93386][7.200:217](),[7.217][7.2558:2558](),[7.93386][7.2549:2557](),[7.2557][7.2558:2558]()
    use crate::config::*;
    use crate::repository::Repository;
    use anyhow::bail;
    use log::debug;
    use std::io::Write;
    use std::path::Path;
    pub struct Key {
    #[clap(subcommand)]
    subcmd: Option<SubCommand>,
    }
    pub enum SubCommand {
    }
    impl Key {
    pub async fn run(self) -> Result<(), anyhow::Error> {
    match self.subcmd {
    if let Some(mut dir) = global_config_dir() {
    dir.push("secretkey.json");
    if std::fs::metadata(&dir).is_ok() {
    bail!("Cannot overwrite key file {:?}", dir)
    }
    debug!("creating file {:?}", dir);
    let mut f = open_secret_file(&dir)?;
    let pass = rpassword::read_password_from_tty(Some(
    "Password for the new key (press enter to leave it unencrypted): ",
    ))?;
    let pass = if pass.is_empty() {
    None
    } else {
    Some(pass.as_ref())
    };
    serde_json::to_writer_pretty(&mut f, &k.save(pass))?;
    f.write_all(b"\n")?;
    let mut stderr = std::io::stderr();
    writeln!(stderr, "Wrote secret key in {:?}", dir)?;
    dir.pop();
    dir.push("publickey.json");
    debug!("creating file {:?}", dir);
    let mut f = std::fs::File::create(&dir)?;
    f.write_all(b"\n")?;
    }
    }
    }
    Ok(())
    }
    }
    #[cfg(unix)]
    fn open_secret_file(path: &Path) -> Result<std::fs::File, std::io::Error> {
    use std::fs::OpenOptions;
    use std::os::unix::fs::OpenOptionsExt;
    OpenOptions::new()
    .write(true)
    .create(true)
    .mode(0o600)
    .open(path)
    }
    #[cfg(not(unix))]
    fn open_secret_file(path: &Path) -> Result<std::fs::File, std::io::Error> {
    std::fs::File::create(path)
    }
    Some(SubCommand::Prove {
    remote,
    no_cert_check,
    }) => {
    let mut remote = if let Ok(repo) = Repository::find_root(None) {
    use crate::remote::*;
    if let RemoteRepo::Ssh(ssh) = repo
    .await?
    {
    ssh
    } else {
    bail!("No such remote: {}", remote)
    }
    } else if let Some(mut ssh) = crate::remote::ssh::ssh_remote(&remote, false) {
    } else {
    bail!("No such remote: {}", remote)
    };
    remote.prove(key).await?;
    }
    None => {
    }
    Self::command().write_long_help(&mut std::io::stdout())?;
    let (_, key) = super::load_key()?;
    if let Some(c) = ssh.connect(&remote, crate::DEFAULT_CHANNEL).await? {
    c
    } else {
    bail!("No such remote: {}", remote)
    }
    .remote(
    None,
    &remote,
    crate::DEFAULT_CHANNEL,
    Direction::Pull,
    no_cert_check,
    false,
    )
    let pk = k.public_key();
    serde_json::to_writer_pretty(&mut f, &pk)?;
    f.write_all(b"\n")?;
    dir.pop();
    dir.push("identities");
    std::fs::create_dir_all(&dir)?;
    dir.push(&pk.key);
    debug!("creating file {:?}", dir);
    let mut f = std::fs::File::create(&dir)?;
    serde_json::to_writer_pretty(
    &mut f,
    &super::Identity {
    public_key: pk,
    origin: String::new(),
    email,
    name: None,
    last_modified: std::time::SystemTime::now()
    .duration_since(std::time::SystemTime::UNIX_EPOCH)
    .unwrap()
    .as_secs(),
    },
    )?;
    login,
    let k = libpijul::key::SKey::generate(None);
    std::fs::create_dir_all(&dir)?;
    Some(SubCommand::Generate { email, login }) => {
    Generate {
    #[clap(long = "email")]
    email: Option<String>,
    },
    login: String,
    },
    Prove {
    #[clap(short = 'k')]
    no_cert_check: bool,
    remote: String,
    /// Associate a generated key with a remote identity. Patches authored
    /// by unproven keys will only display the key as the author. Example
    /// of proving a key (after generating one): `pijul key prove <nestlogin>@ssh.pijul.com`
    /// Generate a new key. The name used for a key is not required to
    /// are stored in your global configuration directory.
    /// match a user's remote or SSH credentials. By default, new keys
    #[derive(Parser, Debug)]
    #[derive(Parser, Debug)]
    use clap::{CommandFactory, Parser};
  • file addition: identity.rs (----------)
    [7.93386]
    use crate::{
    config,
    identity::{choose_identity_name, fix_identities, Complete},
    Identity,
    };
    use std::{convert::TryInto, io::Write};
    use anyhow::bail;
    use chrono::{DateTime, NaiveDateTime, Utc};
    use clap::Parser;
    use dialoguer::Confirm;
    use keyring::Entry;
    use ptree::{print_tree, TreeBuilder};
    mod subcmd {
    use anyhow::bail;
    use chrono::{DateTime, Utc};
    use clap::{ArgGroup, Parser};
    fn validate_email(input: &str) -> Result<String, anyhow::Error> {
    if validator::validate_email(input) {
    Ok(input.to_string())
    } else {
    bail!("Invalid email address");
    }
    }
    fn valid_name(input: &str) -> Result<(), anyhow::Error> {
    if input.is_empty() {
    bail!("Name is empty");
    } else {
    Ok(())
    }
    }
    fn name_does_not_exist(input: &str) -> Result<String, anyhow::Error> {
    valid_name(&input)?;
    if crate::identity::Complete::load(input).is_ok() {
    bail!("Name already exists")
    } else {
    Ok(input.to_string())
    }
    }
    fn name_exists(input: &str) -> Result<String, anyhow::Error> {
    valid_name(&input)?;
    if crate::identity::Complete::load(input).is_err() {
    bail!("Name does not exist");
    } else {
    Ok(input.to_string())
    }
    }
    fn parse_expiry(input: &str) -> Result<DateTime<Utc>, anyhow::Error> {
    let parsed_date = dateparser::parse_with_timezone(input, &chrono::offset::Utc);
    if parsed_date.is_err() {
    bail!("Invalid date");
    }
    let date = parsed_date.unwrap();
    if chrono::offset::Utc::now().timestamp_millis() > date.timestamp_millis() {
    bail!("Date is in the past")
    } else {
    Ok(date)
    }
    }
    #[derive(Clone, Parser, Debug)]
    #[clap(group(
    ArgGroup::new("edit-data")
    .multiple(true)
    .requires("no-prompt")
    .args(&["display-name", "email", "expiry", "username", "remote", "name"]),
    ))]
    pub struct New {
    /// Do not automatically link keys with the remote
    #[clap(long = "no-link", display_order = 1)]
    pub no_link: bool,
    /// Abort rather than prompt for input
    #[clap(long = "no-prompt", requires("edit-data"), display_order = 1)]
    pub no_prompt: bool,
    /// Set the username
    #[clap(long = "username", display_order = 3)]
    pub username: Option<String>,
    /// Set the default remote
    #[clap(long = "remote", display_order = 3)]
    pub remote: Option<String>,
    /// Set the display name
    #[clap(long = "display-name", display_order = 3)]
    pub display_name: Option<String>,
    /// Set the email
    #[clap(long = "email", value_parser = validate_email, display_order = 3)]
    pub email: Option<String>,
    /// Set the new identity name
    #[clap(long = "name", value_parser = name_does_not_exist, display_order = 3)]
    pub name: Option<String>,
    /// Set the expiry
    #[clap(long = "expiry", value_parser = parse_expiry, display_order = 3)]
    pub expiry: Option<DateTime<Utc>>,
    /// Encrypt using a password from standard input
    #[clap(long = "read-password", display_order = 2)]
    pub password: bool,
    }
    #[derive(Clone, Parser, Debug)]
    #[clap(group(
    ArgGroup::new("edit-data")
    .multiple(true)
    .requires("no-prompt")
    .args(&["display-name", "email", "new-name", "expiry", "username", "remote"]),
    ))]
    pub struct Edit {
    /// Set the name of the identity to edit
    #[clap(long = "name", group("name"), value_parser = name_exists, display_order = 2)]
    pub old_name: Option<String>,
    /// Do not automatically link keys with the remote
    #[clap(long = "no-link", display_order = 1)]
    pub no_link: bool,
    /// Abort rather than prompt for input
    #[clap(
    long = "no-prompt",
    requires("name"),
    requires("edit-data"),
    display_order = 1
    )]
    pub no_prompt: bool,
    /// Set the username
    #[clap(long = "username", display_order = 3)]
    pub username: Option<String>,
    /// Set the default remote
    #[clap(long = "remote", display_order = 3)]
    pub remote: Option<String>,
    /// Set the display name
    #[clap(long = "display-name", display_order = 3)]
    pub display_name: Option<String>,
    /// Set the email
    #[clap(long = "email", value_parser = validate_email, display_order = 3)]
    pub email: Option<String>,
    /// Set the identity name
    #[clap(long = "new-name", display_order = 3)]
    pub new_name: Option<String>,
    /// Set the expiry
    #[clap(long = "expiry", value_parser = parse_expiry, display_order = 3)]
    pub expiry: Option<DateTime<Utc>>,
    /// Encrypt using a password from standard input
    #[clap(long = "read-password", display_order = 2)]
    pub password: bool,
    }
    }
    #[derive(Clone, Parser, Debug)]
    pub enum SubCommand {
    /// Create a new identity
    New(subcmd::New),
    /// Repair the identity state on disk, including migration from older versions of Pijul
    Repair {
    /// Abort rather than prompt for input
    #[clap(long = "no-prompt")]
    no_prompt: bool,
    },
    /// Prove an identity to the server
    Prove {
    /// Set the name used to prove the identity
    #[clap(long = "name")]
    identity_name: Option<String>,
    /// Abort rather than prompt for input
    #[clap(long = "no-prompt")]
    no_prompt: bool,
    },
    /// Pretty-print all valid identities on disk
    List,
    /// Edit an existing identity
    Edit(subcmd::Edit),
    /// Remove an existing identity
    Remove {
    /// Set the name of the identity to remove
    #[clap(long = "name")]
    identity_name: Option<String>,
    /// Remove the matching identity without confirmation
    #[clap(long = "no-confirm")]
    no_confirm: bool,
    },
    }
    #[derive(Clone, Parser, Debug)]
    pub struct IdentityCommand {
    #[clap(subcommand)]
    subcmd: SubCommand,
    /// Do not verify certificates (use with caution)
    #[clap(long = "no-cert-check", short = 'k')]
    no_cert_check: bool,
    }
    fn unwrap_args(
    default: Complete,
    identity_name: Option<String>,
    login: Option<String>,
    display_name: Option<String>,
    origin: Option<String>,
    email: Option<String>,
    expiry: Option<DateTime<Utc>>,
    password: bool,
    ) -> Result<Complete, anyhow::Error> {
    let pw = if password {
    Some(
    dialoguer::Password::with_theme(config::load_theme()?.as_ref())
    .with_prompt("Secret key password")
    .with_confirmation("Confirm password", "Password mismatch")
    .interact()?,
    )
    } else {
    None
    };
    let secret_key = libpijul::key::SKey::generate(expiry);
    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(),
    },
    link_remote: true,
    })
    }
    impl IdentityCommand {
    pub async fn run(self) -> Result<(), anyhow::Error> {
    let mut stderr = std::io::stderr();
    crate::identity::NO_CERT_CHECK
    .set(self.no_cert_check)
    .unwrap();
    match self.subcmd {
    SubCommand::New(options) => {
    let identity = if options.no_prompt {
    unwrap_args(
    Complete::default()?,
    options.name,
    options.username,
    options.display_name,
    options.remote,
    options.email,
    options.expiry,
    options.password,
    )?
    } else {
    Complete::default()?
    };
    identity.create(options.no_prompt, !options.no_link).await?;
    }
    SubCommand::Repair { no_prompt } => fix_identities(no_prompt).await?,
    SubCommand::Prove {
    identity_name,
    no_prompt,
    } => {
    Complete::load(&identity_name.unwrap_or(choose_identity_name(no_prompt).await?))?
    .prove(self.no_cert_check)
    .await?;
    }
    SubCommand::List => {
    let identities = Complete::load_all()?;
    if identities.is_empty() {
    let mut stderr = std::io::stderr();
    writeln!(
    stderr,
    "No identities found. Use `pijul identity new` to create one."
    )?;
    writeln!(stderr, "If you have created a key in the past, you may need to migrate via `pijul identity repair`")?;
    return Ok(());
    }
    let mut tree = TreeBuilder::new("Identities".to_string());
    for identity in identities {
    tree.begin_child(identity.name.clone());
    tree.add_empty_child(format!(
    "Name: {}",
    identity
    .identity
    .name
    .unwrap_or_else(|| "<NO NAME>".to_string())
    ));
    tree.add_empty_child(format!(
    "Email: {}",
    identity
    .identity
    .email
    .unwrap_or_else(|| "<NO EMAIL>".to_string())
    ));
    let login = if identity.identity.login.is_empty() {
    String::from("<NO USERNAME>")
    } else {
    identity.identity.login
    };
    let origin = if identity.identity.origin.is_empty() {
    String::from("<NO ORIGIN>")
    } else {
    identity.identity.origin
    };
    tree.add_empty_child(format!("Login: {login}@{origin}"));
    tree.begin_child("Public key".to_string());
    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!(
    "Algorithm: {:#?}",
    identity.identity.public_key.algorithm
    ));
    tree.add_empty_child(format!(
    "Expiry: {}",
    identity
    .identity
    .public_key
    .expires
    .map(|date| date.format("%Y-%m-%d %H:%M:%S (UTC)").to_string())
    .unwrap_or_else(|| "Never".to_string())
    ));
    tree.end_child();
    tree.begin_child("Secret key".to_string());
    tree.add_empty_child(format!("Version: {}", identity.secret_key.version));
    tree.add_empty_child(format!(
    "Algorithm: {:#?}",
    identity.secret_key.algorithm
    ));
    let encryption_message =
    if let Some(encryption) = identity.secret_key.encryption {
    format!(
    "{} (Stored in keyring: {})",
    match encryption {
    libpijul::key::Encryption::Aes128(_) => "AES 128-bit",
    },
    keyring::Entry::new("pijul", &identity.name)
    .get_password()
    .is_ok()
    )
    } else {
    String::from("None")
    };
    tree.add_empty_child(format!("Encryption: {encryption_message}"));
    tree.end_child();
    let naive_time = NaiveDateTime::from_timestamp(
    identity.identity.last_modified.try_into()?,
    0,
    );
    let date: DateTime<Utc> = DateTime::from_utc(naive_time, Utc);
    tree.add_empty_child(format!(
    "Last updated: {}",
    date.format("%Y-%m-%d %H:%M:%S (UTC)")
    ));
    tree.end_child();
    }
    print_tree(&tree.build())?;
    }
    SubCommand::Edit(options) => {
    let old_id_name = if let Some(id_name) = options.old_name {
    id_name
    } else {
    choose_identity_name(options.no_prompt).await?
    };
    let old_identity = Complete::load(&old_id_name)?;
    let new_identity = if options.no_prompt {
    unwrap_args(
    old_identity.clone(),
    options.new_name,
    options.username,
    options.display_name,
    options.remote,
    options.email,
    options.expiry,
    options.password,
    )?
    } else {
    old_identity.clone().prompt_changes(true)?
    };
    old_identity.clone().replace_with(new_identity.clone())?;
    // User has changed credentials, so attempt to re-prove
    if 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?;
    }
    }
    }
    SubCommand::Remove {
    identity_name,
    no_confirm: no_prompt,
    } => {
    let identity = Complete::load(
    &identity_name.unwrap_or(choose_identity_name(no_prompt).await?),
    )?;
    let path = config::global_config_dir()
    .unwrap()
    .join("identities")
    .join(&identity.name);
    writeln!(stderr, "Removing identity: {identity} at {path:?}")?;
    // Ask the user to confirm
    if !no_prompt
    && !Confirm::with_theme(config::load_theme()?.as_ref())
    .with_prompt("Do you wish to continue?")
    .default(false)
    .interact()?
    {
    bail!("User did not wish to continue");
    }
    // The user has confirmed, safe to continue
    std::fs::remove_dir_all(path)?;
    writeln!(stderr, "Identity removed.")?;
    if identity.secret_key.encryption.is_some() {
    Entry::new("pijul", &identity.name).delete_password()?;
    }
    }
    }
    Ok(())
    }
    }