feat: add config file and password saving

This commit is contained in:
Anna 2023-08-30 21:14:20 -04:00
parent fae094abf0
commit fae01b126b
Signed by: anna
GPG Key ID: D0943384CD9F87D1
4 changed files with 1113 additions and 66 deletions

837
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -10,13 +10,17 @@ anyhow = "1"
chrono = "0.4"
clap = { version = "4", features = ["derive"] }
data-encoding = "2"
directories = "5"
flate2 = "1"
git2 = "0.17"
gpgme = "0.11"
indicatif = "0.17"
inquire = "0.6"
itoa = "1"
keyring = "2"
num_cpus = "1"
rand = "0.8"
sequoia-openpgp = { git = "https://gitlab.com/sequoia-pgp/sequoia" }
serde = { version = "1", features = ["derive"] }
sha1 = "0.10"
toml = "0.7"

27
config.example.toml Normal file
View File

@ -0,0 +1,27 @@
# All fields are optional - omit/comment them if not including them
# The PGP key ID to use for signing - this will use gpg-agent and is incredibly
# slow. Consider exporting your key to a file and providing it below.
# Note that providing an ID or file on the command-line will override either in
# the config.
signing_key_id = ''
# The PGP secret key file to use for signing - this will use sequoia and is
# orders of magnitude faster than using gpg-agent.
# Note that providing an ID or file on the command-line will override either in
# the config.
signing_key_file = ''
# The number of threads to use. 0 for system's amount of logical cores
threads = 0
# The method to use for generating hashes.
# increment - increase timestamp by 1 second until prefix is found
# decrement - decrease timestamp by 1 second until prefix is found
# random - append a random string in the commit message until prefix is found
# counter - append a string containing the number of tries until prefix is
# found
# header - add a git commit header to the commit with an increasing counter
# until prefix is found (this is invisible but may break if git
# changes how it parses headers)
method = 'counter'

View File

@ -1,17 +1,19 @@
use std::borrow::Cow;
use std::fs::File;
use std::io::Write;
use std::io::{ErrorKind, Write};
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicUsize, Ordering};
use anyhow::{Context as _, Result};
use clap::{Parser, ValueEnum};
use directories::ProjectDirs;
use flate2::Compression;
use flate2::write::ZlibEncoder;
use git2::Repository;
use gpgme::{Context, Protocol, SignMode};
use indicatif::ProgressStyle;
use keyring::Entry;
use openpgp::{
packet::prelude::*,
parse::Parse,
@ -23,7 +25,9 @@ use openpgp::{
};
use rand::Rng;
use sequoia_openpgp as openpgp;
use sequoia_openpgp::crypto::KeyPair;
use sequoia_openpgp::policy::StandardPolicy;
use serde::Deserialize;
use sha1::{Digest, Sha1};
/// A vanity commit hash generator that's fast, works with PGP signing, and can
@ -46,21 +50,28 @@ struct Cli {
///
/// This is very slow compared to providing a key file, but this will use
/// gpg-agent.
#[arg(short = 'k', long)]
#[arg(short = 'k', long, value_name = "KEY-ID")]
signing_key: Option<String>,
/// Path to an export secret key to use for signing. This is orders of
/// magnitude faster than providing a key ID, but it will not use gpg-agent
/// and requires you to provide your password to git-vain.
#[arg(short = 'K', long)]
#[arg(short = 'K', long, value_name = "KEY-FILE")]
signing_key_file: Option<String>,
/// The number of threads to use when searching for a matching hash.
/// 0 will use the number of logical cores on your system.
#[arg(short, long, default_value_t = 0)]
threads: usize,
/// Prevent signing of commits. This is useful to override signing keys
/// configured in the config file.
#[arg(long)]
no_sign: bool,
/// The method of changing the commit to generate a new hash.
/// The number of threads to use when searching for a matching hash.
/// Defaults to 0, which will use the number of logical cores on your
/// system.
#[arg(short, long)]
threads: Option<usize>,
/// The method of changing the commit to generate a new hash. Defaults to
/// counter.
///
/// Note that this has no effect if a signing key is provided.
///
@ -77,19 +88,42 @@ struct Cli {
/// - header will add an invalid git header line to the commit with an
/// incrementing counter (git ignores additional headers, but this may not
/// be compatible with future header changes)
#[arg(short, long, value_enum, default_value_t = Method::Counter)]
method: Method,
#[arg(short, long, value_enum)]
method: Option<Method>,
/// Force generating a new vanity hash for commits that already start with
/// the requested prefix.
#[arg(short, long)]
force: bool,
/// If this flag is specified, the program will print where it expects an
/// optional config file to be placed, then it will exit.
#[arg(long, exclusive = true)]
print_config_path: bool,
/// A key file to save a password for. This prograam will prompt for a
/// password, then save the given password to your system's key store and
/// use it to decrypt your secret key for future operations. The program
/// will exit after saving the password.
///
/// This can be used across multiple invocations to save passwords for
/// multiple keys.
#[arg(long, exclusive = true, value_name = "KEY-FILE")]
save_password: Option<String>,
/// Instruct git-vain to forget the password for the key ID specified.
///
/// Key IDs are the last 16 characters of the key's ID in `gpg -K`.
#[arg(long, exclusive = true, value_name = "KEY-ID")]
forget_password: Option<String>,
/// The vanity commit hash prefix you desire.
prefix: String,
#[arg(required_unless_present_any(["print_config_path", "save_password", "forget_password"]))]
prefix: Option<String>,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Method {
Decrement,
Increment,
@ -98,64 +132,215 @@ enum Method {
Header,
}
#[derive(Deserialize)]
struct Config {
signing_key_id: Option<String>,
signing_key_file: Option<String>,
threads: Option<usize>,
method: Option<Method>,
}
fn get_config(args: &Cli) -> Result<Option<Config>> {
let dirs = match ProjectDirs::from("lgbt.anna", "", "git-vain") {
Some(d) => d,
None => {
if args.print_config_path {
eprintln!("no home directory could be found");
}
return Ok(None);
}
};
let config_path = dirs.config_dir().join("config.toml");
if args.print_config_path {
println!("{}", config_path.to_string_lossy());
return Ok(None);
}
let raw_config = match std::fs::read_to_string(&config_path) {
Ok(c) => c,
Err(e) if e.kind() == ErrorKind::NotFound => {
return Ok(None);
}
Err(e) => return Err(e).context("could not read config file"),
};
toml::from_str::<Config>(&raw_config)
.map(Some)
.context("could not parse config file")
}
fn unlock_key_file(path: &str, check_secrets: bool, return_password: bool) -> Result<(KeyPair, Option<String>)> {
let key = openpgp::Cert::from_file(path)
.context("failed to read key from file")?;
let mut pw = None;
let mut keys = Vec::new();
let p = &StandardPolicy::new();
for key in key.keys()
.with_policy(p, None)
.alive()
.revoked(false)
.for_signing()
.secret()
.map(|ka| ka.key())
{
let key = if key.has_unencrypted_secret() {
key.clone().into_keypair()
.context("failed to convert unencrypted key into keypair")?
} else {
if check_secrets {
let key_id = key.keyid().to_hex();
let entry = Entry::new(PW_SERVICE, &key_id)
.context("could not get secret provider entry")?;
match entry.get_password() {
Ok(p) => match key.clone().decrypt_secret(&p.into()) {
Ok(decrypted) => {
let pair = decrypted.into_keypair()
.context("could not convert key into keypair")?;
keys.push(pair);
continue;
}
Err(_) => {
eprintln!("warn: saved password for key {} was incorrect", key_id);
}
},
Err(keyring::Error::NoEntry) => {}
Err(e) => {
eprintln!("warn: could not get saved password for key {}: {:#}", key_id, e);
},
}
}
let mut first = true;
loop {
let mut prompt = inquire::Password::new("Key password: ");
if !first {
prompt = prompt.with_help_message("Failed to decrypt the secret key with that password");
}
let password = prompt.without_confirmation()
.prompt()?;
let decrypted = key.clone()
.decrypt_secret(&password.trim().into())
.context("failed to decrypt secret key");
if return_password {
pw = Some(password);
}
first = false;
match decrypted {
Ok(d) => break d.into_keypair()
.context("failed to convert decrypted key into keypair")?,
Err(_) => continue,
}
}
};
keys.push(key);
}
if keys.is_empty() {
anyhow::bail!("no suitable signing keys in key file");
}
// we know keys isn't empty
Ok((
keys.pop().unwrap(),
pw,
))
}
const PW_SERVICE: &str = "lgbt.anna.git-vain";
fn main() -> Result<()> {
let args = Cli::parse();
let config = get_config(&args)
.context("could not get configuration")?;
if args.print_config_path {
return Ok(());
}
if args.save_password.is_some() && args.forget_password.is_some() {
anyhow::bail!("cannot save and forget passwords at the same time");
}
if let Some(key_path) = args.save_password {
let (key, password) = unlock_key_file(&key_path, false, true)
.context("could not get keypair")?;
let password = password.context("failed to get password")?;
let id = key.public().keyid().to_hex();
let entry = Entry::new(PW_SERVICE, &id)
.context("could not create entry in secrets provider")?;
entry.set_password(&password)
.context("failed to set password in secrets provider")?;
println!("password for key {id} saved");
return Ok(());
}
if let Some(id) = args.forget_password {
let entry = Entry::new(PW_SERVICE, &id)
.context("could not look up entry in secrets provider")?;
return match entry.delete_password() {
Ok(()) => {
println!("password for key {id} forgotten");
Ok(())
}
Err(keyring::Error::NoEntry) => {
eprintln!("no password saved for key {id}");
Ok(())
}
Err(e) => Err(e).context("could not delete password"),
};
}
if args.signing_key.is_some() && args.signing_key_file.is_some() {
anyhow::bail!("specify a key id or key file, not both");
}
let prefix = args.prefix.to_lowercase();
let threads = match args.threads {
0 => num_cpus::get(),
x => x,
if let Some(config) = &config {
if config.signing_key_id.is_some() && config.signing_key_file.is_some() {
anyhow::bail!("specify a key id or key file in the config file, not both");
}
}
let prefix = args.prefix
.expect("clap should handle this")
.to_lowercase();
let threads = match args.threads.or_else(|| config.as_ref().and_then(|c| c.threads)) {
None | Some(0) => num_cpus::get(),
Some(x) => x,
};
let key_file = if args.no_sign {
None
} else {
match &args.signing_key_file {
Some(f) => Some(f),
None if args.signing_key.is_none() => config.as_ref()
.and_then(|c| c.signing_key_file.as_ref()),
None => None,
}
};
let key_id = if args.no_sign {
None
} else {
match &args.signing_key {
Some(i) => Some(i),
None if args.signing_key_file.is_none() => config.as_ref()
.and_then(|c| c.signing_key_id.as_ref()),
None => None,
}
};
let mut seq_key = None;
if let Some(path) = &args.signing_key_file {
let key = openpgp::Cert::from_file(path)
.context("failed to read key from file")?;
let mut keys = Vec::new();
let p = &StandardPolicy::new();
for key in key
.keys().with_policy(p, None).alive().revoked(false).for_signing().secret()
.map(|ka| ka.key())
{
let key = if key.has_unencrypted_secret() {
key.clone().into_keypair()
.context("failed to convert unencrypted key into keypair")?
} else {
let mut first = true;
loop {
let mut prompt = inquire::Password::new("Key password: ");
if !first {
prompt = prompt.with_help_message("Failed to decrypt the secret key with that password");
}
let password = prompt.without_confirmation()
.prompt()?;
let decrypted = key.clone()
.decrypt_secret(&password.trim().into())
.context("failed to decrypt secret key");
first = false;
match decrypted {
Ok(d) => break d.into_keypair()
.context("failed to convert decrypted key into keypair")?,
Err(_) => continue,
}
}
};
keys.push(key);
}
if keys.is_empty() {
anyhow::bail!("no suitable signing keys in key file");
}
seq_key = keys.pop();
if let Some(path) = key_file {
seq_key = Some(unlock_key_file(path, true, false).context("could not get keypair")?.0);
}
// first open the repo and find the commit pointed to by HEAD
@ -237,7 +422,7 @@ fn main() -> Result<()> {
for _ in 0..threads {
let bar = bar.clone();
let mut seq_key = seq_key.clone();
let key = args.signing_key.clone();
let key = key_id.cloned();
let counter = Arc::clone(&counter);
let found = Arc::clone(&found);
let timestamp = Arc::clone(&timestamp);
@ -246,7 +431,9 @@ fn main() -> Result<()> {
let mut author_parts = author_parts.clone();
let mut committer_parts = committer_parts.clone();
let mut header_lines = header_lines.clone();
let method = args.method;
let method = args.method
.or_else(|| config.as_ref().and_then(|c| c.method))
.unwrap_or(Method::Counter);
let commit_tx = commit_tx.clone();
std::thread::spawn(move || {