use super::load::path;
use super::Complete;
use crate::repository::Repository;
use std::io::Write;
use std::{fs, path::PathBuf};
use anyhow::{bail, Context};
use dialoguer::{Confirm, FuzzySelect, Input};
use keyring::Entry;
use log::{debug, error, warn};
use thrussh_keys::key::PublicKey;
use once_cell::sync::OnceCell;
use crate::config;
pub static NO_CERT_CHECK: OnceCell<bool> = OnceCell::new();
impl Complete {
pub async fn prompt_changes(
&self,
to_replace: Option<&String>,
link_remote: bool,
) -> Result<Self, anyhow::Error> {
let mut new_identity = self.clone();
let config = config::load_theme()?;
new_identity.name = Input::with_theme(config.as_ref())
.with_prompt("Unique identity name")
.default(String::from("default"))
.allow_empty(false)
.with_initial_text(if to_replace.is_some() {
self.name.clone()
} else {
String::new()
})
.validate_with(move |input: &String| -> Result<(), String> {
if input.contains(['/', '\\', '.']) {
return Err("Name contains illegal characters".to_string());
}
match Self::load(input) {
Ok(existing_identity) => {
if let Some(name) = to_replace {
if name == input {
Ok(())
} else {
Err(format!("The identity {existing_identity} already exists. Either remove the identity or edit it directly."))
}
} else {
Err(format!("The identity {existing_identity} already exists. Either remove the identity or edit it directly."))
}
}
Err(_) => Ok(()),
}
})
.interact_text()?;
new_identity.config.author.display_name = Input::with_theme(config.as_ref())
.with_prompt("Display name")
.allow_empty(true)
.with_initial_text(&self.config.author.display_name)
.interact_text()?;
new_identity.config.author.email = Input::with_theme(config.as_ref())
.with_prompt("Email (leave blank for none)")
.allow_empty(true)
.with_initial_text(&self.config.author.email)
.validate_with(move |input: &String| -> Result<(), &str> {
if input.is_empty() || validator::validate_email(input) {
Ok(())
} else {
Err("Invalid email address")
}
})
.interact_text()?;
if Confirm::with_theme(config.as_ref())
.with_prompt(&format!(
"Do you want to change the encryption? (Current status: {})",
self.credentials
.clone()
.unwrap()
.secret_key
.encryption
.map_or("not encrypted", |_| "encrypted")
))
.default(false)
.interact()?
{
new_identity.change_password()?;
}
new_identity.prompt_expiry()?;
if Confirm::with_theme(config.as_ref())
.with_prompt("Do you want to link this identity to a remote?")
.default(true)
.interact()?
{
new_identity.prompt_remote().await?;
if to_replace.is_none()
|| self.secret_key() != new_identity.secret_key()
|| (&self.config.author.origin, &self.config.author.username)
!= (
&new_identity.config.author.origin,
&new_identity.config.author.username,
)
{
if link_remote
&& new_identity
.prove(None, *NO_CERT_CHECK.get_or_init(|| false))
.await
.is_err()
{
error!("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 {
new_identity.config.key_path = None;
new_identity.config.author.username = String::new();
new_identity.config.author.origin = String::new();
}
new_identity.last_modified = chrono::offset::Utc::now();
Ok(new_identity)
}
async fn prompt_ssh(&mut self) -> Result<(), anyhow::Error> {
let mut ssh_agent = thrussh_keys::agent::client::AgentClient::connect_env().await?;
let identities = ssh_agent.request_identities().await?;
let ssh_dir = dirs_next::home_dir().unwrap().join(".ssh");
let selection = FuzzySelect::with_theme(config::load_theme()?.as_ref())
.with_prompt("Select key")
.items(
&identities
.iter()
.map(|id| {
format!(
"{}: {} ({})",
id.name(),
id.fingerprint(),
ssh_dir
.join(match id {
PublicKey::Ed25519(_) =>
thrussh_keys::key::ED25519.identity_file(),
PublicKey::RSA { ref hash, .. } => hash.name().identity_file(),
})
.display(),
)
})
.collect::<Vec<_>>(),
)
.default(0)
.interact()?;
self.config.key_path = Some(ssh_dir.join(match identities[selection] {
PublicKey::Ed25519(_) => thrussh_keys::key::ED25519.identity_file(),
PublicKey::RSA { ref hash, .. } => hash.name().identity_file(),
}));
Ok(())
}
async fn prompt_remote(&mut self) -> Result<(), anyhow::Error> {
let config = config::load_theme()?;
self.config.author.username = Input::with_theme(config.as_ref())
.with_prompt("Remote username")
.default(whoami::username())
.with_initial_text(&self.config.author.username)
.interact_text()
.unwrap();
self.config.author.origin = Input::with_theme(config.as_ref())
.with_prompt("Remote URL")
.with_initial_text(&self.config.author.origin)
.default(String::from("ssh.pijul.com"))
.interact_text()
.unwrap();
if Confirm::with_theme(config.as_ref())
.with_prompt(&format!(
"Do you want to change the default SSH key? (Current key: {})",
if let Some(path) = &self.config.key_path {
format!("{path:#?}")
} else {
String::from("none")
}
))
.default(false)
.interact()
.unwrap()
{
self.prompt_ssh().await?;
}
debug!("prompt remote {:?}", self.config.author);
Ok(())
}
fn prompt_expiry(&mut self) -> Result<(), anyhow::Error> {
let config = config::load_theme()?;
let expiry_message = self
.public_key
.expires
.map(|date| date.format("%Y-%m-%d %H:%M:%S").to_string());
self.public_key.expires = 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
};
Ok(())
}
fn write_config(&self, identity_dir: &PathBuf) -> Result<(), anyhow::Error> {
let config_data = toml::to_string_pretty(&self)?;
let mut config_file = std::fs::File::create(identity_dir.join("identity.toml"))?;
config_file.write_all(config_data.as_bytes())?;
Ok(())
}
fn write_secret_key(&self, identity_dir: &PathBuf) -> Result<(), anyhow::Error> {
let key_data = serde_json::to_string_pretty(&self.secret_key())?;
let mut key_file = std::fs::File::create(&identity_dir.join("secret_key.json"))?;
key_file.write_all(key_data.as_bytes())?;
Ok(())
}
fn write(&self) -> Result<(), anyhow::Error> {
if let Ok(existing_identity) = Self::load(&self.name) {
bail!("An identity with that name already exists: {existing_identity}");
}
let identity_dir = path(&self.name, false)?;
std::fs::create_dir_all(&identity_dir)?;
self.write_config(&identity_dir)?;
self.write_secret_key(&identity_dir)?;
Ok(())
}
pub async fn prove(
&self,
origin: Option<&str>,
no_cert_check: bool,
) -> Result<(), anyhow::Error> {
let remote = origin.unwrap_or(&self.config.author.origin);
let mut stderr = std::io::stderr();
writeln!(
stderr,
"Linking identity `{}` with {}@{}",
&self.name, &self.config.author.username, remote
)?;
let mut remote = if let Ok(repo) = Repository::find_root(None) {
repo.remote(
None,
Some(&self.config.author.username),
&remote,
crate::DEFAULT_CHANNEL,
no_cert_check,
false,
)
.await?
} else {
crate::remote::unknown_remote(
None,
Some(&self.config.author.username),
&remote,
crate::DEFAULT_CHANNEL,
no_cert_check,
false,
)
.await?
};
let (key, _password) = self.credentials.clone().unwrap().decrypt(&self.name)?;
remote.prove(key).await?;
Ok(())
}
pub async fn create(&self, no_prompt: bool, link_remote: bool) -> Result<(), anyhow::Error> {
let confirmed_identity = if no_prompt {
self.clone()
} else {
self.prompt_changes(None, link_remote).await?
};
confirmed_identity.write()?;
Ok(())
}
pub fn replace_with(self, new_identity: Self) -> Result<Self, anyhow::Error> {
let changed_names = self.name != new_identity.name;
if changed_names {
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.")?;
let new_identity_path = path(&new_identity.name, false)?;
debug!("Creating new directory: {new_identity_path:?}");
fs::create_dir_all(new_identity_path).context("Could not create new identity.")?;
new_identity.write()?;
if let Err(e) = Entry::new("pijul", &self.name).and_then(|x| x.delete_password()) {
warn!("Unable to delete password: {e:?}");
}
} else {
let identity_dir = path(&new_identity.name, false)?;
if self.config != new_identity.config {
new_identity.write_config(&identity_dir)?;
}
if self.secret_key() != new_identity.secret_key() {
new_identity.write_secret_key(&identity_dir)?;
}
}
if let Some(password) = new_identity.credentials.clone().unwrap().password.get() {
if let Err(e) =
Entry::new("pijul", &new_identity.name).and_then(|x| x.set_password(&password))
{
warn!("Unable to set password: {e:?}");
}
} else if let Err(e) =
Entry::new("pijul", &new_identity.name).and_then(|x| x.delete_password())
{
warn!("Unable to delete password: {e:?}");
}
Ok(new_identity)
}
}