read from an instance of GnuPG

This commit is contained in:
kalmenn 2026-02-02 08:40:49 +01:00
parent 6aa7e23872
commit a844ec48a4
Signed by: kalmenn
GPG key ID: F500055C44BC3834
8 changed files with 1267 additions and 35 deletions

859
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -7,8 +7,8 @@ edition = "2024"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
[workspace.dependencies] [workspace.dependencies]
anyhow = "1.0.100" anyhow = "1.0"
clap = { version = "4.5.56", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
[package] [package]
name = "git-identity" name = "git-identity"
@ -18,10 +18,18 @@ license.workspace = true
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
chrono = "0.4.43"
clap.workspace = true clap.workspace = true
thiserror = "2.0.18" console = "0.16"
tracing = "0.1.44" dialoguer = { version = "0.12", features = ["fuzzy-select"] }
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } dirs = "6.0"
gpgme = "0.11"
itertools = "0.14.0"
serde = { version = "1.0", features = ["derive"] }
thiserror = "2.0"
toml = "0.9"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[profile.release] [profile.release]
lto = true lto = true

21
TODO.md
View file

@ -3,17 +3,7 @@
## import ## import
Import an identity from a gpg key. Import an identity from a gpg key.
Relevant crates: We still need to save it somewhere
```Cargo.toml
# For finding the cache directory in which to store the SSH keys exported from GPG and identities
dirs = "6.0.0"
# For importing GPG keys
gpgme = "0.11.0"
# For reading/writing identities' files
serde = { version = "1.0.228", features = ["derive"] }
toml = "0.9.11"
```
## use ## use
Set the identity of the current git repo Set the identity of the current git repo
@ -37,9 +27,12 @@ List all saved identities
Interactively edit a saved identity (or in a text editor when using a specific flag) Interactively edit a saved identity (or in a text editor when using a specific flag)
# User input # User input
[inquire](https://docs.rs/inquire/latest/inquire/) seems simple enough. But Currently, dialoguer (which uses the console crate) doesn't reset the terminal state properly when
[dialoguer](https://docs.rs/dialoguer/latest/dialoguer/) is the one use by tauri which seems much the user exists using ctrl-c. Most importantly, the cursor remains hidden even after the program
more complete. exited.
Maybe switching to [inquire](https://docs.rs/inquire/latest/inquire/) would fix this. If not, we
might need to check if console emits `std::io::ErrorKind::Interrupted` like it seems to me it does
and whether or not we can react to it easily.
# Completions # Completions
clap_complete does that statically. It will be able to do it dynamically once the clap_complete does that statically. It will be able to do it dynamically once the

View file

@ -1,6 +1,10 @@
//! The Command Line Interface definition to the git-identity subcommand //! The Command Line Interface definition to the git-identity subcommand
use std::{borrow::Cow, sync::LazyLock};
use clap::{ArgAction, Args, Parser, Subcommand}; use clap::{ArgAction, Args, Parser, Subcommand};
use console::Term;
use dialoguer::theme::ColorfulTheme;
/// Manages multiple identities (e.g. personal, work, university, ...) in git repos for you. /// Manages multiple identities (e.g. personal, work, university, ...) in git repos for you.
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@ -15,7 +19,7 @@ pub struct Cli {
impl Cli { impl Cli {
pub fn run_subcommand(self) -> anyhow::Result<()> { pub fn run_subcommand(self) -> anyhow::Result<()> {
match self.command { match self.command {
Command::SayHello(cli) => crate::subcommands::say_hello::main(self.global, cli), Command::Import(cli) => crate::subcommands::import::main(self.global, cli),
} }
} }
} }
@ -32,11 +36,19 @@ pub struct GlobalArgs {
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
pub enum Command { pub enum Command {
SayHello(SayHelloCli), Import(ImportCli),
} }
/// A temporary subcommand for testing the cli
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
pub struct SayHelloCli { pub struct ImportCli {
pub name: String, /// A list of patterns to filter GPG keys with, works the same way as patterns you use with
/// the gpg command
#[arg(id = "PATTERN")]
pub patterns: Vec<Cow<'static, str>>,
} }
pub static THEME: LazyLock<ColorfulTheme> = LazyLock::new(|| ColorfulTheme {
..Default::default()
});
pub static STDERR: LazyLock<Term> = LazyLock::new(Term::stderr);

View file

@ -1,6 +1,14 @@
//! This crate is not a library but we need some things to be exported so that they can be read by //! This crate is not a library but we need some things to be exported so that they can be read by
//! cargo xtasks //! cargo xtasks
use std::{path::PathBuf, sync::LazyLock};
pub mod cli; pub mod cli;
pub mod logging; pub mod logging;
pub mod subcommands; pub mod subcommands;
pub static DATA_FOLDER: LazyLock<PathBuf> = LazyLock::new(|| {
let mut dir = dirs::data_dir().expect("");
dir.push("git-identity");
dir
});

365
src/subcommands/import.rs Normal file
View 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))
}

View file

@ -1 +1 @@
pub mod say_hello; pub mod import;

View file

@ -1,7 +0,0 @@
pub fn main(
_global_args: crate::cli::GlobalArgs,
cli: crate::cli::SayHelloCli,
) -> anyhow::Result<()> {
println!("Hello {}!", cli.name);
Ok(())
}