use super::load::path;
use super::Complete;
use crate::config;
use crate::config::Direction;
use crate::repository::Repository;
use std::fs;
use std::io::Write;
use anyhow::{bail, Context};
use dialoguer::{Confirm, FuzzySelect, Input};
use keyring::Entry;
use log::{debug, warn};
use once_cell::sync::OnceCell;
use thiserror::Error;
use thrussh_keys::key::PublicKey;
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 {
pub async fn prompt_changes(
&self,
replace_current: bool,
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 replace_current {
self.name.clone()
} else {
String::new()
})
.validate_with(move |input: &String| -> Result<(), String> {
match Self::load(input) {
Ok(existing_identity) => {
if replace_current {
Ok(())
} else {
Err(format!("That identity already exists: {existing_identity}",))
}
}
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(link_remote).await?;
} 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, link_remote: bool) -> Result<(), IdentityCreateError> {
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?;
}
if link_remote
&& self
.prove(*NO_CERT_CHECK.get_or_init(|| false))
.await
.is_err()
{
return Err(IdentityCreateError::ProveFailed(self.name.clone()));
}
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(&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}");
}
}
let identity_dir = path(&self.name, false)?;
std::fs::create_dir_all(&identity_dir)?;
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())?;
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(())
}
pub async fn prove(&self, no_cert_check: bool) -> Result<(), anyhow::Error> {
let mut stderr = std::io::stderr();
writeln!(
stderr,
"Linking identity {}@{}",
&self.config.author.username, &self.config.author.origin
)?;
let remote = self.config.author.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, _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<(), IdentityCreateError> {
let confirmed_identity = if no_prompt {
self.clone()
} else {
self.prompt_changes(false, link_remote).await?
};
confirmed_identity.write(false)?;
Ok(())
}
pub fn replace_with(self, new_identity: Self) -> Result<Self, anyhow::Error> {
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.")?;
new_identity.write(true)?;
if self.name != new_identity.name {
if let Err(e) = Entry::new("pijul", &self.name).delete_password() {
warn!("Unable to delete password: {e:?}");
}
}
if let Some(password) = new_identity.credentials.clone().unwrap().password.get() {
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)
}
}