read from an instance of GnuPG
This commit is contained in:
parent
6aa7e23872
commit
a844ec48a4
8 changed files with 1267 additions and 35 deletions
365
src/subcommands/import.rs
Normal file
365
src/subcommands/import.rs
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
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 identity_name = dialoguer::Input::<String>::with_theme(&*THEME)
|
||||
.with_prompt("What should this identity be named?")
|
||||
.interact_text()?;
|
||||
|
||||
let authkey: Option<PathBuf> = select_authkey(&mut ctx, &key, &identity_name)?;
|
||||
|
||||
let (name, email) = select_name_and_email(&key)?;
|
||||
|
||||
info!(
|
||||
"Imported identity: [{identity_name}] {name:?} <{email:?}> (signing key: {fingerprint}, authentication key: {authkey:?})"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select_key<P>(ctx: &mut gpgme::Context, patterns: P) -> anyhow::Result<gpgme::Key>
|
||||
where
|
||||
P: IntoIterator,
|
||||
Vec<u8>: From<<P as IntoIterator>::Item>,
|
||||
{
|
||||
let patterns: Vec<CString> = patterns
|
||||
.into_iter()
|
||||
.map(|p| Ok(CString::new(p)?))
|
||||
.collect::<anyhow::Result<_>>()
|
||||
.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::<Vec<gpgme::Key>>();
|
||||
|
||||
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<str> = key
|
||||
.fingerprint_raw()
|
||||
.map(CStr::to_string_lossy)
|
||||
.unwrap_or(Cow::Borrowed(&FINGERPRINT_ERRSTR));
|
||||
|
||||
let primary_key: Cow<str> = 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::<String>();
|
||||
|
||||
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<Cow<'static, str>> {
|
||||
let algorithm = key
|
||||
.algorithm()
|
||||
.name_raw()
|
||||
.map(CStr::to_string_lossy)
|
||||
.unwrap_or(Cow::Borrowed(&ALGORITHM_ERRSTR));
|
||||
|
||||
let creation_time: Cow<str> = 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<String> = 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<Option<PathBuf>> {
|
||||
let mut authkey_prompt = dialoguer::Input::<String>::with_theme(&*THEME)
|
||||
.with_prompt("Select an SSH key (leave empty for none)")
|
||||
.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<Option<PathBuf>> {
|
||||
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_FOLDER.clone();
|
||||
path.push("ssh");
|
||||
path.push(format!("{identity_name}.pub"));
|
||||
path
|
||||
};
|
||||
|
||||
let destination: PathBuf = dialoguer::Input::<String>::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<P: AsRef<Path>>(
|
||||
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<String>, Option<String>)> {
|
||||
fn select<'key, G>(
|
||||
key: &'key gpgme::Key,
|
||||
item_kind: &str,
|
||||
getter: G,
|
||||
) -> anyhow::Result<Option<String>>
|
||||
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::<Vec<(&CStr, String)>>();
|
||||
|
||||
let picked: Option<String> = 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::<String>::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))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue