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 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
/// ├── 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)
}
}
}
//! 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 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)
}
}
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)
}
}
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)
}
}
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 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
.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(())
}
}