The main data structure responsible for complete identity management, identity::Complete, contains the Author information, secret key, public key & other associated metadata. Various functions are implemented on the aforementioned struct that handle identity creation, modification, deletion & migration from the old format. These functions are then leveraged in pijul/src/commands/identity.rs to expose the functionality to the user.
4KJ45IJLTIE35KQZUSFMFS67RNENG4P2FZMKMULJLGGYMKJUVRSQC ZSFJT4SFIAS7WBODRZOFKKG4SVYBC5PC6XY75WYN7CCQ3SMV7IUQC CXSCA5HNK3BSSUMZ2SZ5XNKSSHC2IFHPC35SMO5WMWVJBYEFSJTQC NAUECZW353R5RHT4GGQJIEZPA5EYRGQYTSP7IJNBJS3CXBSTNJDQC OFQY3GUUXYY5GTLHRH4NSZMLVXJRO67QK3TVDLNNOHZ7T66ZSRJAC 6ZHY3XTG6JIVKAJTEYS6IRZR3PTRRMISCQGIIPBXLUOCIL72TEWQC SXEYMYF7P4RZMZ46WPL4IZUTSQ2ATBWYZX7QNVMS3SGOYXYOHAGQC OKE6SXPP34GKAXKZTWLNHRJRQQN32T3SQCSOWWC3GTV425ZF5Q6QC EEBKW7VTILH6AGGV57ZIJ3DJGYHDSYBWGU3C7Q4WWAKSVNUGIYMQC A3RM526Y7LUXNYW4TL56YKQ5GVOK2R5D7JJVTSQ6TT5MEXIR6YAAC PIQCNEEBNHZDYOU2O7667XBB6D3V2MUALRRVJX6VO5BGYR7LTYRQC SMMBFECLGSUKRZW5YPOQPOQCOY2CH2OTZXBSZ3KG2N3J3HQZ5PSAC 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 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)}}/// 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 uplet extraction_result = Complete::from_old_format(None);let mut stderr = std::io::stderr();match extraction_result {Ok(old_identity) => {// Migrate to new formatwriteln!(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 disklet identity_path = format!("identities/{}", &old_identity.identity.public_key.key);// Try to delete what remains of the old identitieslet 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 identityif 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 orderfor identity in Complete::load_all()? {identity.valid_keys()?;}Ok(())}impl Complete {/// Checks if the key pair on disk is validfn 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/// ├── 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 formatif !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.jsonlet 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 existinglet 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 identitieslet 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 filesfor 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 needlet 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 passwordpassword = 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)}}}
//! 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//! └── identities///! └── <IDENTITY NAME>///! ├── identity.toml//! │ ├── Username//! │ ├── Full name//! │ └── 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 infopub 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 profilepub 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 matcherimpl 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 contextlet 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, 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)}}
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 emptypub 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 truepub 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 againCHOSEN_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 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))}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 diskpub 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)}}
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 onepub 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 identityOk(())} 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 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,))}/// Write an identity to disk.////// # Arguments/// * `replace_current` - If the new identity will replace an existing onefn 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 fileslet 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 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)}/// Replace an existing identity with a new one.////// # Arguments/// * `new_identity` - The new identity that will be createdpub fn replace_with(self, new_identity: Self) -> Result<Self, anyhow::Error> {// Remove the old datalet 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 datanew_identity.write(true)?;// Remove the old keychain entry if name has changedif self.name != new_identity.name {if let Err(e) = Entry::new("pijul", &self.name).delete_password() {warn!("Unable to delete password: {e:?}");}}// Update the passwordif 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)}}
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};
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 identityNew(subcmd::New),/// Repair the identity state on disk, including migration from older versions of PijulRepair {/// Abort rather than prompt for input#[clap(long = "no-prompt")]no_prompt: bool,},/// Prove an identity to the serverProve {/// 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 diskList,/// Edit an existing identityEdit(subcmd::Edit),/// Remove an existing identityRemove {/// 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.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-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?;}}}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 confirmif !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 continuestd::fs::remove_dir_all(path)?;writeln!(stderr, "Identity removed.")?;if identity.secret_key.encryption.is_some() {Entry::new("pijul", &identity.name).delete_password()?;}}}Ok(())}}