git-vain/src/main.rs

709 lines
26 KiB
Rust

use std::borrow::Cow;
use std::fs::File;
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 rand::Rng;
use sequoia_openpgp::{
self as openpgp,
crypto::KeyPair,
parse::Parse,
policy::StandardPolicy,
serialize::stream::{Armorer, Message, Signer},
};
use serde::Deserialize;
use sha1::{Digest, Sha1};
/// A vanity commit hash generator that's fast, works with PGP signing, and can
/// be used on arbitrary commits (not just HEAD).
#[derive(Parser)]
#[command(author, version)]
struct Cli {
/// The commit to replace with a vanity hash.
#[arg(short, long, default_value_t = String::from("HEAD"))]
commit: String,
/// The key ID to use for signing.
///
/// This is very slow compared to providing a key file, but this will use
/// gpg-agent.
#[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, value_name = "KEY-FILE")]
signing_key_file: Option<String>,
/// Prevent signing of commits. This is useful to override signing keys
/// configured in the config file.
#[arg(long)]
no_sign: bool,
/// 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.
///
/// - decrement will decrease the commit date by one second for each attempt
///
/// - increment will increase the commit date by one second for each attempt
///
/// - random will append a random string to the commit message for each
/// attempt
///
/// - counter will append an increasing number to the commit message for
/// each attempt
///
/// - 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)]
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.
#[arg(required_unless_present_any(["print_config_path", "save_password", "forget_password"]))]
prefix: Option<String>,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Method {
Decrement,
Increment,
Random,
Counter,
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";
const NIBBLES: [[u8; 16]; 16] = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
[16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31],
[32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47],
[48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63],
[64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79],
[80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95],
[96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111],
[112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127],
[128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143],
[144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159],
[160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175],
[176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191],
[192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207],
[208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223],
[224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239],
[240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255],
];
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");
}
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();
if !prefix.chars().all(|c| c.is_ascii_hexdigit()) {
anyhow::bail!("prefix must be hexadecimal");
}
let (byte_prefix, nibble_values) = if prefix.len() % 2 == 0 {
// even
let bytes = data_encoding::HEXLOWER.decode(prefix.as_bytes())
.context("could not decode prefix as hex")?;
(bytes, None)
} else {
// odd
let bytes = data_encoding::HEXLOWER.decode(prefix[..prefix.len() - 1].as_bytes())
.context("could not decode prefix as hex")?;
let last = prefix.as_bytes()[prefix.len() - 1];
let idx = match last {
48..=57 => last - 48,
97..=102 => last - 97 + 10,
_ => unreachable!(),
};
let nibble = NIBBLES[idx as usize];
(bytes, Some(nibble))
};
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) = key_file {
seq_key = Some(unlock_key_file(path, true, false).context("could not get keypair")?.0);
}
if (key_id.is_some() || seq_key.is_some()) && args.method.is_some() {
eprintln!("warn: method specified will be ignored: commit signing is enabled (try with --no-sign)");
}
// first open the repo and find the commit pointed to by HEAD
let repo = Repository::open(".")
.context("no git repository in this directory")?;
let obj = repo.revparse_single(&args.commit)
.context("could not find reference")?;
let commit = repo.find_commit(obj.id())
.context("could not resolve reference to a commit")?;
if !args.force && commit.id().to_string().starts_with(&prefix) {
anyhow::bail!("commit already starts with prefix");
}
// grab the header of the commit and the message
let raw_header = commit.raw_header()
.context("commit had an invalid header")?;
let mut header_lines = Vec::default();
// strip out the signature lines
{
let mut in_sig = false;
for line in raw_header.split('\n') {
if line.starts_with("gpgsig ") {
in_sig = true;
continue;
}
if in_sig {
if line.starts_with(' ') {
continue;
} else {
in_sig = false;
}
}
header_lines.push(line.to_string());
}
}
let stripped_header = header_lines.join("\n");
let message = commit.message_raw()
.context("commit had an invalid message")?
.to_string();
// find the author and committer
let author_idx = header_lines.iter()
.position(|line| line.starts_with("author "))
.context("missing author")?;
let committer_idx = header_lines.iter()
.position(|line| line.starts_with("committer "))
.context("missing committer")?;
// split the lines by spaces
let author_parts: Vec<_> = header_lines[author_idx].split(' ')
.map(ToOwned::to_owned)
.collect();
let committer_parts: Vec<_> = header_lines[committer_idx].split(' ')
.map(ToOwned::to_owned)
.collect();
// get the timestamp
let timestamp = author_parts.get(author_parts.len() - 2)
.context("missing timestamp")?
.parse::<i64>()
.context("invalid timestamp")?;
let author_len = author_parts.len();
let committer_len = committer_parts.len();
let counter = Arc::new(AtomicUsize::default());
let found = Arc::new(AtomicBool::new(false));
let timestamp = Arc::new(AtomicI64::new(timestamp));
let (commit_tx, commit_rx) = std::sync::mpsc::channel();
let bar = indicatif::ProgressBar::new_spinner()
.with_style(ProgressStyle::with_template("{spinner} {elapsed} - {human_len} hashes ({per_sec})")?);
for _ in 0..threads {
let bar = bar.clone();
let seq_key = seq_key.clone();
let key = key_id.cloned();
let counter = Arc::clone(&counter);
let found = Arc::clone(&found);
let timestamp = Arc::clone(&timestamp);
let message = message.clone();
let byte_prefix = byte_prefix.clone();
let mut author_parts = author_parts.clone();
let mut committer_parts = committer_parts.clone();
let mut header_lines = header_lines.clone();
let stripped_header = stripped_header.clone();
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 || {
let mut gpg = match key {
Some(key) => {
let mut ctx = Context::from_protocol(Protocol::OpenPgp)
.context("could not create openpgp context")
.unwrap(); // FIXME
ctx.set_armor(true);
let key = ctx.get_secret_key(key)
.context("could not find secret key")
.unwrap(); // FIXME
ctx.add_signer(&key)
.context("could not add signer")
.unwrap(); // FIXME
Some(ctx)
}
None => None,
};
let signing = gpg.is_some() || seq_key.is_some();
let mut signature_bytes = Vec::with_capacity(1024);
let mut sha1 = Sha1::default();
let mut buffer = itoa::Buffer::new();
let mut count_buffer = itoa::Buffer::new();
let mut random_bytes = [0; 16];
let mut random_hex = [0; 32];
let mut first = true;
while !found.load(Ordering::Relaxed) {
let (mut header, append) = if !signing {
let append = match method {
Method::Random => {
rand::thread_rng().fill(&mut random_bytes);
data_encoding::HEXLOWER.encode_mut(&random_bytes, &mut random_hex);
let random = unsafe {
std::str::from_utf8_unchecked(&random_hex)
};
Some(random)
}
Method::Counter => {
let count = counter.fetch_add(1, Ordering::Relaxed) + 1;
Some(count_buffer.format(count))
}
_ => None,
};
if matches!(method, Method::Increment | Method::Decrement) {
let timestamp = match method {
Method::Decrement => timestamp.fetch_sub(1, Ordering::Relaxed) - 1,
Method::Increment => timestamp.fetch_add(1, Ordering::Relaxed) + 1,
_ => unreachable!(),
};
author_parts[author_len - 2] = buffer.format(timestamp).to_owned();
committer_parts[committer_len - 2] = buffer.format(timestamp).to_owned();
header_lines[author_idx] = author_parts.join(" ");
header_lines[committer_idx] = committer_parts.join(" ");
}
if method == Method::Header {
let count = counter.fetch_add(1, Ordering::Relaxed) + 1;
if first {
header_lines.insert(committer_idx + 1, String::with_capacity(16));
first = false;
}
let line = &mut header_lines[committer_idx + 1];
line.clear();
line.push_str("xvain ");
line.push_str(buffer.format(count));
}
let header = match method {
Method::Counter | Method::Random => Cow::from(&stripped_header),
_ => Cow::from(header_lines.join("\n")),
};
(header, append)
} else {
(Cow::from(&stripped_header), None)
};
// NOTE: don't need to handle append here, since we'll never be
// both appending *and* signing
if let Some(ctx) = &mut gpg {
signature_bytes.clear();
let to_sign = format!("{header}\n{message}");
ctx.sign(SignMode::Detached, to_sign, &mut signature_bytes)
.context("could not sign commit")
.unwrap(); // FIXME
let sig = std::str::from_utf8(&signature_bytes)
.context("signature was not utf-8")
.unwrap(); // FIXME
let header = header.to_mut();
header.push_str("gpgsig");
for line in sig.trim().split('\n') {
header.push(' ');
header.push_str(line);
header.push('\n');
}
} else if let Some(key) = &seq_key {
signature_bytes.clear();
let msg = Message::new(&mut signature_bytes);
let msg = Armorer::new(msg)
.kind(openpgp::armor::Kind::Signature)
.build()
.context("failed to build armorer")
.unwrap(); // FIXME
let mut msg = Signer::new(msg, key.clone())
.detached()
.build()
.context("failed to build signer")
.unwrap(); // FIXME
msg.write_all(header.as_bytes()).unwrap(); // FIXME
msg.write_all(&[b'\n']).unwrap(); // FIXME
msg.write_all(message.as_bytes()).unwrap(); // FIXME
msg.finalize().unwrap(); // FIXME
let sig = std::str::from_utf8(&signature_bytes)
.context("signature was not utf-8")
.unwrap(); // FIXME
let header = header.to_mut();
header.push_str("gpgsig");
for line in sig.trim().split('\n') {
header.push(' ');
header.push_str(line);
header.push('\n');
}
}
let message_len = match &append {
Some(a) => message.len() + a.len() + 2,
None => message.len(),
};
sha1.update("commit ");
sha1.update(buffer.format(header.len() + message_len + 1));
sha1.update([0]);
sha1.update(header.as_bytes());
sha1.update("\n");
match &append {
Some(a) => {
sha1.update(&message);
sha1.update("\n");
sha1.update(a.as_bytes());
sha1.update("\n");
}
None => sha1.update(&message),
}
let hash = sha1.finalize_reset();
bar.inc(1);
if hash[0..byte_prefix.len()] == byte_prefix {
// check the nibble
if let Some(values) = nibble_values {
if !values.contains(&hash[byte_prefix.len()]) {
continue;
}
}
found.store(true, Ordering::Relaxed);
let message = match append {
Some(append) => Cow::from(format!("{message}\n{append}\n")),
None => Cow::from(&message),
};
let total = format!("{header}\n{message}");
let raw_commit = format!("commit {}\0", total.len());
let hash = data_encoding::HEXLOWER.encode(&hash);
commit_tx.send((hash, format!("{raw_commit}{total}"))).unwrap();
break;
}
}
});
}
let (hash, new_commit_data) = commit_rx.recv().unwrap();
bar.finish_and_clear();
let path = repo.path();
// let old_commit_id = commit.id().to_string();
// let old_path = path.join("objects")
// .join(&old_commit_id[..2])
// .join(&old_commit_id[2..]);
let new_path = path.join("objects")
.join(&hash[..2])
.join(&hash[2..]);
{
std::fs::create_dir_all(new_path.parent().unwrap())
.context("could not create directory")?;
let file = File::create(new_path)
.context("could not create commit file")?;
let mut compressor = ZlibEncoder::new(file, Compression::fast());
compressor.write_all(new_commit_data.as_bytes())
.context("could not write commit")?;
}
// let new_commit = repo.find_annotated_commit(Oid::from_str(&hash)?)
// .context("cannot get new commit")?;
// let new_new_commit = repo.find_commit(Oid::from_str(&hash)?)
// .context("cannot get new commit (2)")?;
// let old_commit = repo.revparse_single(&format!("{}~1", commit.id()))
// .context("cannot find old commit")?;
// let old_commit = repo.find_annotated_commit(old_commit.id())
// .context("cannot get new commit")?;
// let mut rebase = repo.rebase(None, Some(&old_commit), Some(&new_commit), None)?;
// while let Some(op) = rebase.next() {
// op?;
// rebase.commit(None, &new_new_commit.committer(), None)?;
// }
// rebase.finish(None)?;
let previous = format!("{}~1", commit.id());
let is_root = repo.revparse_single(&previous).is_err();
let final_arg = if is_root {
"--root"
} else {
&*previous
};
Command::new("git")
.args(["rebase", "--onto", &hash, final_arg])
.spawn()
.context("could not spawn rebase command")?
.wait()
.context("rebase command failed")?;
eprintln!("{hash}");
Ok(())
}