use std::{ borrow::Cow, ffi::{CStr, CString}, fs::File, path::{Path, PathBuf}, sync::LazyLock, time::{Duration, SystemTime}, }; use anyhow::Context as _; use dialoguer::theme::Theme as _; use gpgme::Context; use tracing::{error, info, warn}; use crate::cli::{STDERR, THEME}; pub fn main(_global_cli: crate::cli::GlobalArgs, cli: crate::cli::ImportCli) -> anyhow::Result<()> { let mut ctx = gpgme::Context::from_protocol(gpgme::Protocol::OpenPgp)?; let key = select_key(&mut ctx, cli.patterns.iter().map(Cow::as_ref))?; let fingerprint = key .fingerprint_raw() .context("querying the fingerprint of your key")? .to_str() .expect("this should always be valid UTF-8"); let alias = dialoguer::Input::::with_theme(&*THEME) .with_prompt("What should this identity be named?") .interact_text()?; let authkey: Option = select_authkey(&mut ctx, &key, &alias)?; let (name, email) = select_name_and_email(&key)?; let identity = crate::data::Identity { alias, data: crate::data::IdentityData { name, email, sigkey: Some(fingerprint.to_owned()), authkey, }, }; info!("Imported identity {identity}"); identity.save_interactively()?; Ok(()) } fn select_key

(ctx: &mut gpgme::Context, patterns: P) -> anyhow::Result where P: IntoIterator, Vec: From<

::Item>, { let patterns: Vec = patterns .into_iter() .map(|p| Ok(CString::new(p)?)) .collect::>() .context("Converting patterns to CStrings")?; let mut keys = ctx .find_secret_keys(patterns.iter())? .inspect(|it| { if let Err(err) = it { error!("failed to query information about a key. Got err: {err}"); } }) .filter_map(Result::ok) .collect::>(); match keys.len() { 0 => { STDERR.write_line("Found no matching key")?; std::process::exit(1); } 1 => return Ok(keys.pop().expect("Vec is non empty")), _ => (), } // There is some weird behavior when using FuzzySelect with items spanning multiple lines STDERR.clear_screen()?; let index = dialoguer::FuzzySelect::with_theme(&*THEME) .with_prompt("Select a GPG key to import") .items(keys.iter().map(format_key_for_select)) .highlight_matches(false) .interact()?; Ok(keys.swap_remove(index)) } fn format_key_for_select(key: &gpgme::Key) -> Cow<'static, str> { let fingerprint: Cow = key .fingerprint_raw() .map(CStr::to_string_lossy) .unwrap_or(Cow::Borrowed(&FINGERPRINT_ERRSTR)); let primary_key: Cow = key .primary_key() .and_then(|key| format_subkey(key, true)) .unwrap_or(Cow::Borrowed(&PRIMARY_KEY_ERRSTR)); let user_ids: String = itertools::intersperse( key.user_ids() .map(|uid| Cow::Owned(format!(" uid {}", format_user_id(&uid)))), Cow::Borrowed("\n"), ) .collect(); let subkeys = itertools::intersperse( key.subkeys().map(|key| { Cow::Owned(format!( " {}", format_subkey(key, false).unwrap_or(Cow::Borrowed(&SUBKEY_ERRSTR)) )) }), Cow::Borrowed("\n"), ) .collect::(); let formatted = format!("{primary_key}\n {fingerprint:40}\n{user_ids}\n{subkeys}\n"); Cow::Owned(formatted) } fn format_subkey(key: gpgme::Subkey, is_primary: bool) -> Option> { let algorithm = key .algorithm() .name_raw() .map(CStr::to_string_lossy) .unwrap_or(Cow::Borrowed(&ALGORITHM_ERRSTR)); let creation_time: Cow = key .creation_time() .map(|time| { let creation_time = chrono::DateTime::UNIX_EPOCH + time .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or(Duration::ZERO); let local_creation_time = creation_time.with_timezone(&chrono::Local::now().timezone()); let formatted = local_creation_time.format("%Y-%m-%d").to_string(); Cow::Owned(formatted) }) .unwrap_or(Cow::Borrowed(&CREATION_TIME_ERRSTR)); let capabilities = { let mut caps = String::new(); if key.can_sign() { caps.push('S'); } if key.can_encrypt() { caps.push('E') } if key.can_certify() { caps.push('C'); } if key.can_authenticate() { caps.push('A'); } caps }; let formatted = format!( "{:3} {algorithm:7} {creation_time} [{capabilities}]", match (key.is_secret(), is_primary) { (true, true) => "sec", (false, true) => "pub", (true, false) => "ssb", (false, false) => "syb", }, ); Some(Cow::Owned(formatted)) } fn format_user_id(uid: &gpgme::UserId) -> String { let [name, comment, email] = [uid.name_raw(), uid.comment_raw(), uid.email_raw()] .map(|attr| attr.map(CStr::to_string_lossy).unwrap_or(Cow::Borrowed(""))); format!("{name} ({comment}) <{email}>") } macro_rules! format_error_strings { ($($name:ident = $val:expr;)+) => { $( static $name: LazyLock = LazyLock::new(|| { let mut out = String::new(); THEME .format_error(&mut out, $val) .expect("format failed"); out }); )* } } format_error_strings! { PRIMARY_KEY_ERRSTR = "[failed to query primary key]"; SUBKEY_ERRSTR = "[failed to query subkey]"; FINGERPRINT_ERRSTR = "[failed to query fingerprint]"; ALGORITHM_ERRSTR = "[failed to query algorithm]"; CREATION_TIME_ERRSTR = "[failed to query creation time]"; } fn select_authkey( ctx: &mut gpgme::Context, key: &gpgme::Key, identity_name: &str, ) -> anyhow::Result> { let mut authkey_prompt = dialoguer::Input::::with_theme(&*THEME) .with_prompt("Select an SSH key (leave empty for none)") .validate_with(|input: &String| -> Result<(), Cow> { if input.is_empty() { return Ok(()); } match std::fs::exists(input) { Ok(true) => Ok(()), Ok(false) => Err(Cow::Borrowed("file not found")), Err(err) => Err(Cow::Owned(err.to_string())), } }) .allow_empty(true); if let Some(authkey) = try_export_as_ssh_key(ctx, key, identity_name)? { let default_authkey = authkey.to_string_lossy(); if default_authkey.contains(char::REPLACEMENT_CHARACTER) { warn!( "the path to the key you just imported contains non UTF-8 characters. They will be lost if you enter it as is" ); } authkey_prompt = authkey_prompt.with_initial_text(default_authkey); } let authkey = authkey_prompt.interact_text()?; Ok(if authkey.is_empty() { None } else { Some(PathBuf::from(authkey)) }) } /// Checks if the key has authentication capabilities then handles exporting is as an SSH key /// according to the user's input. fn try_export_as_ssh_key( ctx: &mut gpgme::Context, key: &gpgme::Key, identity_name: &str, ) -> anyhow::Result> { if !key.can_authenticate() || !dialoguer::Confirm::with_theme(&*THEME) .with_prompt( "This key can authenticate. Do you want to export it as an SSH key for later?", ) .interact()? { return Ok(None); } let default_destination = { let mut path = crate::data::DATA_FOLDER.clone(); path.push("ssh"); path.push(format!("{identity_name}.pub")); path }; let destination: PathBuf = dialoguer::Input::::with_theme(&*THEME) .with_prompt("Save as") // FIXME: we are screwed if for example the linux user's name contains non valid UTF-8 .with_initial_text(default_destination.to_string_lossy()) .interact_text()? .into(); let fingerprint = key .fingerprint_raw() .context("querying your key's fingerprint")?; save_ssh_key(ctx, fingerprint, &destination)?; info!("succesfully saved your SSH key to disk"); Ok(Some(destination)) } fn save_ssh_key>( ctx: &mut Context, fingerprint: &CStr, path: P, ) -> anyhow::Result<()> { if std::fs::exists(&path).context("Checking if file already exists")? && !dialoguer::Confirm::with_theme(&*THEME) .with_prompt(format!( "{} already exists. Overwrite?", path.as_ref().display() )) .interact()? { return Ok(()); } let parent = { let mut path = PathBuf::from(path.as_ref()); path.pop(); path }; std::fs::create_dir_all(parent).context("Creating parent directories")?; let mut out = File::options() .create(true) .write(true) .truncate(true) .append(false) .open(&path)?; ctx.export([fingerprint], gpgme::ExportMode::SSH, &mut out) .context("asking GPG to export your SSH key")?; Ok(()) } fn select_name_and_email(key: &gpgme::Key) -> anyhow::Result<(Option, Option)> { fn select<'key, G>( key: &'key gpgme::Key, item_kind: &str, getter: G, ) -> anyhow::Result> where G: Fn(&gpgme::UserId<'key>) -> Option<&'key CStr>, { let mut items_and_uids = key .user_ids() .flat_map(|uid| Some((getter(&uid)?, format_user_id(&uid)))) .collect::>(); let picked: Option = if items_and_uids.is_empty() { None } else { let choice = dialoguer::FuzzySelect::with_theme(&*THEME) .with_prompt(format!( "From which user ID would you like to import your {item_kind}?" )) .items( std::iter::once("None (enter my own or leave empty)") .chain(items_and_uids.iter().map(|pair| pair.1.as_str())), ) .interact()?; if choice == 0 { None } else { let out = items_and_uids.swap_remove(choice - 1).0; drop(items_and_uids); Some(out.to_string_lossy().to_string()) } }; let result = if let Some(item) = picked { Some(item) } else { let input = dialoguer::Input::::with_theme(&*THEME) .with_prompt(format!("Enter your {item_kind} (leave empty for none)")) .allow_empty(true) .interact_text()?; if input.is_empty() { None } else { Some(input) } }; Ok(result) } let name = select(key, "name", gpgme::UserId::name_raw)?; let email = select(key, "email", gpgme::UserId::email_raw)?; Ok((name, email)) }