feat: add config file and password saving
This commit is contained in:
parent
fae094abf0
commit
fae01b126b
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||
|
|
|
@ -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'
|
311
src/main.rs
311
src/main.rs
|
@ -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(×tamp);
|
||||
|
@ -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 || {
|
||||
|
|
Loading…
Reference in New Issue