use crate::commands::common_opts::RepoPath;
use jiff::tz::TimeZone;
use pijul_config::author::Author;
use pijul_identity::{
self as identity, Complete, CreateParams, choose_identity_name, fix_identities,
};
use pijul_remote as remote;
use std::io::Write;
use std::path::Path;
use anyhow::bail;
use clap::Parser;
use jiff::Timestamp;
use keyring::Entry;
use log::{info, warn};
use pijul_interaction::Confirm;
use ptree::{TreeBuilder, print_tree};
mod subcmd {
use anyhow::{anyhow, bail};
use clap::{ArgGroup, Parser, ValueHint};
use jiff::Timestamp;
fn validate_email(input: &str) -> Result<String, anyhow::Error> {
use validator::ValidateEmail;
if input.validate_email() {
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 pijul_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 pijul_identity::Complete::load(input).is_err() {
bail!("Name does not exist");
} else {
Ok(input.to_string())
}
}
fn parse_expiry(input: &str) -> Result<Timestamp, anyhow::Error> {
let parsed_date =
pijul_identity::parse_expiry_date(input).map_err(|_| anyhow!("Invalid date"))?;
if parsed_date <= Timestamp::now() {
Err(anyhow!("Date is in the past"))
} else {
Ok(parsed_date)
}
}
#[derive(Clone, Parser, Debug)]
#[clap(group(
ArgGroup::new("edit_data")
.multiple(true)
.args(&["display_name", "email", "expiry", "username", "remote", "name", "password"]),
))]
pub struct New {
#[clap(long = "no-link", display_order = 1)]
pub no_link: bool,
#[clap(long = "no-prompt", requires("edit_data"), display_order = 1)]
pub no_prompt: bool,
#[clap(long = "username", display_order = 3, value_hint = ValueHint::Username)]
pub username: Option<String>,
#[clap(long = "remote", display_order = 3)]
pub remote: Option<String>,
#[clap(long = "display-name", display_order = 3)]
pub display_name: Option<String>,
#[clap(long = "email", value_parser = validate_email, display_order = 3)]
pub email: Option<String>,
#[clap(value_parser = name_does_not_exist)]
pub name: Option<String>,
#[clap(long = "expiry", value_parser = parse_expiry, display_order = 3)]
pub expiry: Option<Timestamp>,
#[clap(long = "read-password", display_order = 2, requires = "no_prompt")]
pub password: bool,
#[clap(long = "no-keyring")]
pub no_keyring: bool,
}
#[derive(Clone, Parser, Debug)]
#[clap(group(
ArgGroup::new("edit_data")
.multiple(true)
.args(&["display_name", "email", "new_name", "expiry", "username", "remote", "password"]),
))]
pub struct Edit {
#[clap(group("name"), value_parser = name_exists)]
pub old_name: Option<String>,
#[clap(long = "no-link", display_order = 1)]
pub no_link: bool,
#[clap(
long = "no-prompt",
requires("name"),
requires("edit_data"),
display_order = 1
)]
pub no_prompt: bool,
#[clap(long = "username", display_order = 3, value_hint = ValueHint::Username)]
pub username: Option<String>,
#[clap(long = "remote", display_order = 3)]
pub remote: Option<String>,
#[clap(long = "display-name", display_order = 3)]
pub display_name: Option<String>,
#[clap(long = "email", value_parser = validate_email, display_order = 3)]
pub email: Option<String>,
#[clap(long = "new-name", display_order = 3)]
pub new_name: Option<String>,
#[clap(long = "expiry", value_parser = parse_expiry, display_order = 3)]
pub expiry: Option<Timestamp>,
#[clap(long = "read-password", display_order = 2, requires = "no_prompt")]
pub password: bool,
#[clap(long = "no-keyring")]
pub no_keyring: bool,
}
}
#[derive(Clone, Parser, Debug)]
pub enum SubCommand {
New(subcmd::New),
Repair {
#[clap(long = "no-keyring")]
no_keyring: bool,
},
Prove {
#[clap(long = "name")]
identity_name: Option<String>,
server: Option<String>,
#[clap(long = "no-keyring")]
no_keyring: bool,
},
List,
Edit(subcmd::Edit),
#[clap(alias = "rm")]
Remove {
#[clap(long = "name")]
identity_name: Option<String>,
#[clap(long = "no-confirm")]
no_confirm: bool,
#[clap(long = "no-keyring")]
no_keyring: bool,
},
}
#[derive(Parser, Debug)]
pub struct IdentityCommand {
#[clap(subcommand)]
subcmd: SubCommand,
#[clap(flatten)]
repository_path: RepoPath,
#[clap(long = "no-cert-check", short = 'k')]
no_cert_check: bool,
}
fn unwrap_args(
config: &pijul_config::Config,
default: Complete,
identity_name: Option<String>,
login: Option<String>,
display_name: Option<String>,
origin: Option<String>,
email: Option<String>,
expiry: Option<Timestamp>,
no_prompt: bool,
use_keyring: bool,
password: bool,
) -> Result<Complete, anyhow::Error> {
let mut public_key = default.public_key.clone();
if let Some(expiry) = expiry.as_ref() {
public_key.expires = Some(expiry.clone());
}
let pw = if password {
Some(read_password(config, no_prompt)?)
} else {
None
};
let credentials = if let Some(mut key) = default.secret_key() {
if let Some(password) = pw.as_deref() {
let (secret_key, _) = default.decrypt(config, use_keyring)?;
key = secret_key.save(Some(password));
}
if let Some(expiry) = expiry.as_ref() {
key.expires = Some(expiry.clone());
}
Some(identity::Credentials::new(key, pw))
} else {
None
};
Ok(Complete::new(
identity_name.unwrap_or(default.name),
identity::IdentityConfig {
key_path: None,
author: Author {
username: login.unwrap_or(default.config.author.username),
display_name: display_name.unwrap_or(default.config.author.display_name),
email: email.unwrap_or(default.config.author.email),
origin: origin.unwrap_or(default.config.author.origin),
key_path: None,
},
},
public_key,
credentials,
))
}
fn read_password(config: &pijul_config::Config, no_prompt: bool) -> Result<String, anyhow::Error> {
if no_prompt {
use std::io::{BufRead, Write};
fn read_line(prompt: &str) -> Result<String, anyhow::Error> {
let mut stderr = std::io::stderr();
writeln!(stderr, "{prompt}")?;
stderr.flush()?;
let mut input = String::new();
let read = std::io::stdin().lock().read_line(&mut input)?;
if read == 0 {
anyhow::bail!("Unexpected end of standard input")
}
while matches!(input.chars().last(), Some('\n' | '\r')) {
input.pop();
}
Ok(input)
}
loop {
let password = read_line("New password")?;
let confirmation = read_line("Confirm password")?;
if password == confirmation {
return Ok(password);
}
let mut stderr = std::io::stderr();
writeln!(stderr, "Password mismatch")?;
stderr.flush()?;
}
} else {
Ok(pijul_interaction::Password::new(config)?
.with_prompt("New password")
.with_confirmation("Confirm password", "Password mismatch")
.interact()?)
}
}
impl IdentityCommand {
pub fn repository_path(&self) -> Option<&Path> {
self.repository_path.repo_path()
}
pub async fn run(self, config: &pijul_config::Config) -> Result<(), anyhow::Error> {
let mut stderr = std::io::stderr();
match self.subcmd {
SubCommand::New(options) => {
let identity = unwrap_args(
config,
Complete::default(config)?,
options.name,
options.username,
options.display_name,
options.remote,
options.email,
options.expiry,
options.no_prompt,
!options.no_keyring,
options.password,
)?;
identity
.create(
config,
CreateParams {
use_keyring: !options.no_keyring,
link_remote: !options.no_link,
no_prompt: options.no_prompt,
},
)
.await?;
if !options.no_link {
if let Err(_) = remote::prove(
config,
&identity,
None,
self.no_cert_check,
!options.no_keyring,
)
.await
{
warn!(
"Could not prove identity `{}`. Please check your credentials & network connection. If you are on an enterprise network, perhaps try running with `--no-cert-check`. Your data is safe but will not be connected to {} without runnning `pijul identity prove {}`",
identity.name, identity.config.author.origin, identity.name
);
} else {
info!("Identity `{}` was proved to the server", identity);
}
}
}
SubCommand::Repair { no_keyring } => fix_identities(config, !no_keyring).await?,
SubCommand::Prove {
identity_name,
server,
no_keyring,
} => {
let identity_name =
&identity_name.unwrap_or(choose_identity_name(config, !no_keyring).await?);
let loaded_identity = Complete::load(identity_name)?;
remote::prove(
config,
&loaded_identity,
server.as_deref(),
self.no_cert_check,
!no_keyring,
)
.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!(
"Display name: {}",
if identity.config.author.display_name.is_empty() {
"<NO NAME>"
} else {
&identity.config.author.display_name
}
));
tree.add_empty_child(format!(
"Email: {}",
if identity.config.author.email.is_empty() {
"<NO EMAIL>"
} else {
&identity.config.author.email
}
));
let username = if identity.config.author.username.is_empty() {
"<NO USERNAME>"
} else {
&identity.config.author.username
};
let origin = if identity.config.author.origin.is_empty() {
"<NO ORIGIN>"
} else {
&identity.config.author.origin
};
tree.add_empty_child(format!("Login: {username}@{origin}"));
tree.begin_child("Public key".to_string());
tree.add_empty_child(format!("Key: {}", identity.public_key.key));
tree.add_empty_child(format!("Version: {}", identity.public_key.version));
tree.add_empty_child(format!(
"Algorithm: {:#?}",
identity.public_key.algorithm
));
tree.add_empty_child(format!(
"Expiry: {}",
identity
.public_key
.expires
.map(|date| date.strftime("%Y-%m-%d %H:%M:%S (%Z)").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().unwrap().version
));
tree.add_empty_child(format!(
"Algorithm: {:#?}",
identity.secret_key().unwrap().algorithm
));
let encryption_message =
if let Some(encryption) = identity.secret_key().unwrap().encryption {
format!(
"{} (Stored in keyring: {})",
match encryption {
pijul_core::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();
tree.add_empty_child(format!(
"Last updated: {}",
identity
.last_modified
.to_zoned(TimeZone::system())
.strftime("%Y-%m-%d %H:%M:%S (%Z)")
));
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(config, !options.no_keyring).await?
};
writeln!(std::io::stderr(), "Editing identity: {old_id_name}")?;
let old_identity = Complete::load(&old_id_name)?;
let cli_args = unwrap_args(
config,
old_identity.clone(),
options.new_name,
options.username,
options.display_name,
options.remote,
options.email,
options.expiry,
options.no_prompt,
!options.no_keyring,
options.password,
)?;
let new_identity = if options.no_prompt {
cli_args
} else {
cli_args
.prompt_changes(
config,
Some(old_identity.name.clone()),
!options.no_link,
!options.no_keyring,
)
.await?
};
old_identity.clone().replace_with(new_identity.clone())?;
if !options.no_link {
if new_identity.secret_key() != old_identity.secret_key()
|| old_identity.config.author != new_identity.config.author
{
let prove_result = remote::prove(
config,
&new_identity,
None,
self.no_cert_check,
!options.no_keyring,
)
.await;
if let Err(_) = prove_result {
warn!(
"Could not prove identity `{}`. Please check your credentials & network connection. If you are on an enterprise network, perhaps try running with `--no-cert-check`. Your data is safe but will not be connected to {} without runnning `pijul identity prove {}`",
new_identity.name,
new_identity.config.author.origin,
new_identity.name
);
} else {
info!("Identity `{}` was proved to the server", new_identity);
}
}
}
}
SubCommand::Remove {
identity_name,
no_confirm: no_prompt,
no_keyring,
} => {
if Complete::load_all()?.is_empty() {
writeln!(stderr, "No identities to remove!")?;
return Ok(());
}
let identity = Complete::load(
&identity_name.unwrap_or(choose_identity_name(config, !no_keyring).await?),
)?;
let path = pijul_config::global_config_directory()
.unwrap()
.join("identities")
.join(&identity.name);
writeln!(stderr, "Removing identity: {identity} at {path:?}")?;
if !no_prompt
&& !Confirm::new(config)?
.with_prompt("Do you wish to continue?")
.with_default(false)
.interact()?
{
bail!("User did not wish to continue");
}
std::fs::remove_dir_all(path)?;
writeln!(stderr, "Identity removed.")?;
if identity.secret_key().unwrap().encryption.is_some() {
if let Err(e) =
Entry::new("pijul", &identity.name).and_then(|x| x.delete_credential())
{
warn!("Unable to delete password: {e:?}");
}
}
}
}
Ok(())
}
}