20 Commits
Author SHA1 Message Date
nelsbrock c22d4bdb27 chore: release version 0.4.0 2026-05-22 19:53:19 +02:00
nelsbrock 3439e51865 chore: upgrade and update dependencies 2026-05-22 19:52:13 +02:00
nelsbrock a07aaca068 feat: rename --stop-after to --count/-n 2026-05-22 19:51:41 +02:00
nelsbrock 20c0a919c5 refactor: replace with_context calls with context calls 2026-05-10 18:13:47 +02:00
nelsbrock 3ea0de1cdb refactor: remove ref_self variable 2026-05-10 18:08:14 +02:00
nelsbrock 9f992caef9 refactor: fix some pedantic clippy lints 2026-05-10 18:02:54 +02:00
nelsbrock c8bedabe1f fix: determine workers count before prompting for password 2026-05-10 17:51:43 +02:00
nelsbrock e8b83d41cb make small improvements to CLI
- set meaningful value names for options
- add backticks around CLI options in user-facing messages
2026-05-10 17:34:56 +02:00
nelsbrock 6af66fd292 add --workers option 2026-05-10 17:28:01 +02:00
nelsbrock bc07cba585 remove unnecessary clone() 2026-05-10 16:21:11 +02:00
nelsbrock c6b5020795 release version 0.3.0 2026-05-09 16:30:04 +02:00
nelsbrock c74dcd0f01 add --userid option 2026-05-09 16:27:59 +02:00
nelsbrock 06e528ac09 upgrade and update dependencies 2026-05-09 16:27:32 +02:00
nelsbrock 890d8fa0be release version 0.2.2 2026-04-04 17:11:43 +02:00
nelsbrock 112a6b087b upgrade dependencies 2026-04-04 17:05:36 +02:00
nelsbrock fe984f2e43 release version 0.2.1 2025-11-01 09:42:39 +01:00
nelsbrock b1450070d1 use a bounded channel and fix panic in race condition 2025-11-01 09:40:42 +01:00
nelsbrock 241fecd8a7 release version 0.2.0 2025-10-25 19:32:59 +02:00
nelsbrock 273c1059a2 fix README.md 2025-10-25 19:28:58 +02:00
nelsbrock 1f659c33a7 stop gracefully on ctrl-c 2025-10-25 18:54:40 +02:00
5 changed files with 730 additions and 545 deletions
Generated
+570 -449
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "fingerprunk" name = "fingerprunk"
version = "0.1.0" version = "0.4.0"
authors = ["Niklas Elsbrock <mail@nelsbrock.de>"] authors = ["Niklas Elsbrock <mail@nelsbrock.de>"]
edition = "2024" edition = "2024"
description = "CLI tool for brute-forcing OpenPGP keys with cool fingerprints" description = "CLI tool for brute-forcing OpenPGP keys with cool fingerprints"
@@ -10,10 +10,10 @@ keywords = ["fingerprint", "openpgp", "bruteforce"]
categories = ["command-line-utilities"] categories = ["command-line-utilities"]
[dependencies] [dependencies]
anyhow = "1.0.100" anyhow = "1.0.102"
clap = { version = "4.5.50", features = ["derive"] } clap = { version = "4.6.1", features = ["derive"] }
fancy-regex = "0.16.2" ctrlc = "3.5.2"
fancy-regex = "0.18.0"
num-integer = "0.1.46" num-integer = "0.1.46"
num_cpus = "1.17.0" rpassword = "7.5.2"
rpassword = "7.4.0" sequoia-openpgp = "2.3.0"
sequoia-openpgp = "2.0.0"
+10 -10
View File
@@ -18,11 +18,11 @@ at `secret.asc`. The regex for this is `^C0FFEE`. Now, simply use the following
the search: the search:
```sh ```sh
fingerprunk -r '^C0FFEE' >> secret.asc fingerprunk -r '^C0FFEE' -u "Your Name <your.email@example.org>" >> secret.asc
``` ```
Fingerprunk will now generate many keys and write out all keys with matching fingerprints to Fingerprunk will now generate many keys and write out all keys with matching fingerprints to
standard output (here: `secret.asc`). standard output (here: `secret.asc`), adding the provided user ID.
If you want Fingerprunk to output password-encrypted keys use the `-p` flag and you will be prompted If you want Fingerprunk to output password-encrypted keys use the `-p` flag and you will be prompted
for a password. for a password.
@@ -51,7 +51,7 @@ Also see <https://en.wikipedia.org/wiki/Hexspeak> for some further examples of "
### How long does it take? ### How long does it take?
On my machine with an AMD Ryzen 7 5800X processor, Fingerprunk is able to generate and check about On my machine with an AMD Ryzen 7 5800X processor, Fingerprunk is able to generate and check about
41000 keys per second. This means that for finding a fingerprint with a string of *n* specific 43500 keys per second. This means that for finding a fingerprint with a string of *n* specific
hexadecimal digits at a specific place, I could expect the following runtimes until finding the hexadecimal digits at a specific place, I could expect the following runtimes until finding the
first key: first key:
@@ -60,15 +60,15 @@ first key:
| 1 | 16 = 16¹ | < 0.1 secs | | 1 | 16 = 16¹ | < 0.1 secs |
| 2 | 256 = 16² | < 0.1 secs | | 2 | 256 = 16² | < 0.1 secs |
| 3 | 4096 = 16³ | 0.1 secs | | 3 | 4096 = 16³ | 0.1 secs |
| 4 | 65536 = 16⁴ | 1.6 secs | | 4 | 65536 = 16⁴ | 1.5 secs |
| 5 | 1048576 = 16⁵ | 26 secs | | 5 | 1048576 = 16⁵ | 24 secs |
| 6 | 16777216 = 16⁶ | 7 mins | | 6 | 16777216 = 16⁶ | 6 mins |
| 7 | 268435456 = 16⁷ | 2 hours | | 7 | 268435456 = 16⁷ | 2 hours |
| 8 | 4294967296 = 16⁸ | 1 days | | 8 | 4294967296 = 16⁸ | 1 days |
| 9 | 68719476736 = 16⁹ | 19 days | | 9 | 68719476736 = 16⁹ | 18 days |
| 10 | 1099511627776 = 16¹⁰ | 310 days | | 10 | 1099511627776 = 16¹⁰ | 293 days |
| 11 | 17592186044416 = 16¹¹ | 14 years | | 11 | 17592186044416 = 16¹¹ | 13 years |
| 12 | 281474976710656 = 16¹² | 218 years | | 12 | 281474976710656 = 16¹² | 205 years |
As you can see, anything above 10 fixed digits is pretty much unfeasible, at least with a normal As you can see, anything above 10 fixed digits is pretty much unfeasible, at least with a normal
personal computer. personal computer.
+97 -68
View File
@@ -3,13 +3,13 @@
use std::{ use std::{
fmt::{self, Write}, fmt::{self, Write},
io, io,
num::NonZeroU64, num::NonZero,
sync::{ sync::{
atomic::{AtomicBool, AtomicU64, Ordering}, atomic::{AtomicBool, AtomicU64, Ordering},
mpsc, mpsc,
}, },
thread, thread,
time::{Duration, Instant}, time::{Duration, Instant, SystemTime},
}; };
use fancy_regex::Regex; use fancy_regex::Regex;
@@ -18,7 +18,7 @@ use sequoia_openpgp::{
Cert, Packet, armor, Cert, Packet, armor,
crypto::Password, crypto::Password,
packet::{ packet::{
Key, Key, UserID,
key::{Key4, PrimaryRole, SecretParts}, key::{Key4, PrimaryRole, SecretParts},
prelude::SignatureBuilder, prelude::SignatureBuilder,
}, },
@@ -28,12 +28,20 @@ use sequoia_openpgp::{
type SecretKey = Key<SecretParts, PrimaryRole>; type SecretKey = Key<SecretParts, PrimaryRole>;
#[allow(clippy::large_enum_variant)]
enum Message {
Key(SecretKey),
Stop,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Config { pub struct Config {
pub regex: Regex, pub regex: Regex,
pub status_enabled: bool, pub status_enabled: bool,
pub stop_after: Option<NonZeroU64>, pub count: Option<NonZero<u64>>,
pub password: Option<Password>, pub password: Option<Password>,
pub userids: Vec<UserID>,
pub workers: NonZero<usize>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -63,54 +71,67 @@ impl Fingerprunk {
} }
} }
pub fn run(mut self) { pub fn run(mut self) -> anyhow::Result<()> {
self.started_instant = Instant::now(); self.started_instant = Instant::now();
let (tx, rx) = mpsc::channel(); let (sender, receiver) = mpsc::sync_channel(16);
{
let sender = sender.clone();
ctrlc::set_handler(move || {
let _ = sender.send(Message::Stop);
})?;
}
thread::scope(|scope| { thread::scope(|scope| {
const THREAD_SPAWN_EXPECT_MSG: &str = "should be able to spawn thread";
let ref_self = &self;
let status_displayer = if self.config.status_enabled { let status_displayer = if self.config.status_enabled {
Some( Some(
thread::Builder::new() thread::Builder::new()
.name("status_displayer".to_string()) .name("status_displayer".to_string())
.spawn_scoped(scope, move || ref_self.status_displayer_thread()) .spawn_scoped(scope, || self.status_displayer_thread())?,
.expect(THREAD_SPAWN_EXPECT_MSG),
) )
} else { } else {
None None
}; };
for num in 0..num_cpus::get() { for num in 0..self.config.workers.get() {
let tx = tx.clone();
thread::Builder::new() thread::Builder::new()
.name(format!("worker-{num:03}")) .name(format!("worker-{num:03}"))
.spawn_scoped(scope, move || ref_self.worker_thread(tx)) .spawn_scoped(scope, || self.worker_thread(&sender))?;
.expect(THREAD_SPAWN_EXPECT_MSG);
} }
let on_stop = || { let mut stdout = io::stdout().lock();
// Ask all other threads to stop
self.stop.store(true, Ordering::Relaxed);
// Unpark the status displayer thread // Receive and process messages from the workers and the ctrl-c handler
if let Some(status_displayer) = status_displayer { for message in receiver {
status_displayer.thread().unpark(); match message {
Message::Key(key) => {
let cert = self.key_to_cert(key)?;
self.serialize_cert(&cert, &mut stdout)?;
// Increase "found" counter and stop if enough matches have been found
let prev = self.counter_found.fetch_add(1, Ordering::Relaxed);
if self.config.count.is_some_and(|s| prev + 1 == s.get()) {
break;
}
}
Message::Stop => break,
} }
}; }
thread::Builder::new() // Ask all other threads to stop
.name("finalizer".to_string()) self.stop.store(true, Ordering::Relaxed);
.spawn_scoped(scope, move || ref_self.finalizer_thread(rx, on_stop))
.expect(THREAD_SPAWN_EXPECT_MSG); // Unpark the status displayer thread, if existant
}); if let Some(status_displayer) = status_displayer {
status_displayer.thread().unpark();
}
Ok(())
})
} }
fn worker_thread(&self, matches_tx: mpsc::Sender<SecretKey>) { fn worker_thread(&self, sender: &mpsc::SyncSender<Message>) {
let mut fingerprint_hex = String::with_capacity(20 * 2); let mut fingerprint_hex = String::with_capacity(20 * 2);
while !self.stop.load(Ordering::Relaxed) { while !self.stop.load(Ordering::Relaxed) {
@@ -120,9 +141,9 @@ impl Fingerprunk {
write!(fingerprint_hex, "{:X}", key.fingerprint()) write!(fingerprint_hex, "{:X}", key.fingerprint())
.expect("should write into string without error"); .expect("should write into string without error");
if self.check_fingerprint(&fingerprint_hex) { if self.check_fingerprint(&fingerprint_hex) {
matches_tx // The channel might already be closed here if we're stopping.
.send(Key::V4(key)) // That is fine, so we just ignore the error.
.expect("should be able to send key"); let _ = sender.send(Message::Key(Key::V4(key)));
} }
self.counter_tried.fetch_add(1, Ordering::Relaxed); self.counter_tried.fetch_add(1, Ordering::Relaxed);
} }
@@ -136,56 +157,50 @@ impl Fingerprunk {
.expect("should check regex without error") .expect("should check regex without error")
} }
fn finalizer_thread(&self, matches_rx: mpsc::Receiver<SecretKey>, on_stop: impl FnOnce()) { fn key_to_cert(&self, mut key: SecretKey) -> anyhow::Result<Cert> {
let mut stdout = io::stdout().lock(); let creation_time = SystemTime::now();
for key in matches_rx {
let cert = self
.key_to_cert(&key)
.expect("should be able to create certificate");
self.serialize_cert(cert, &mut stdout)
.expect("should be able to serialize certificate");
let prev = self.counter_found.fetch_add(1, Ordering::Relaxed);
if self.config.stop_after.is_some_and(|s| prev + 1 == s.get()) {
break;
}
}
on_stop();
}
fn key_to_cert(&self, key: &SecretKey) -> anyhow::Result<Cert> {
let sig = SignatureBuilder::new(SignatureType::DirectKey)
.set_hash_algo(HashAlgorithm::SHA512)
.set_preferred_hash_algorithms(vec![HashAlgorithm::SHA512, HashAlgorithm::SHA256])?
.set_preferred_symmetric_algorithms(vec![
SymmetricAlgorithm::AES256,
SymmetricAlgorithm::AES128,
])?;
let mut signer = key let mut signer = key
.clone() .clone()
.into_keypair() .into_keypair()
.expect("key should have a secret"); .expect("key should have a secret");
let sig = sig.sign_direct_key(&mut signer, key.parts_as_public())?;
let secret_key_packet = Packet::SecretKey({ // Sign keypair
let mut key = key.clone(); let key_sig = create_sig_builder(SignatureType::DirectKey, creation_time)?
.sign_direct_key(&mut signer, key.parts_as_public())?;
// Create certificate
let mut cert = Cert::try_from(Packet::SecretKey({
if let Some(ref password) = self.config.password { if let Some(ref password) = self.config.password {
let (k, mut secret) = key.take_secret(); let (k, mut secret) = key.take_secret();
secret.encrypt_in_place(&k, password)?; secret.encrypt_in_place(&k, password)?;
key = k.add_secret(secret).0; key = k.add_secret(secret).0;
} }
key key
}); }))?;
Cert::try_from(vec![secret_key_packet, Packet::from(sig)]) let mut packets = vec![Packet::from(key_sig)];
// Sign user IDs
let mut next_is_primary = true;
for user_id in self.config.userids.iter().cloned() {
let mut sig_builder =
create_sig_builder(SignatureType::PositiveCertification, creation_time)?;
if next_is_primary {
sig_builder = sig_builder.set_primary_userid(true)?;
next_is_primary = false;
}
let sig = user_id.bind(&mut signer, &cert, sig_builder)?;
packets.push(user_id.into());
packets.push(sig.into());
}
cert = cert.insert_packets(packets)?.0;
Ok(cert)
} }
fn serialize_cert(&self, cert: Cert, to: impl io::Write) -> anyhow::Result<()> { fn serialize_cert(&self, cert: &Cert, to: impl io::Write) -> anyhow::Result<()> {
let mut comments = cert.armor_headers(); let mut comments = cert.armor_headers();
comments.push(format!( comments.push(format!(
"Generated with Fingerprunk. Regex: {}", "Generated with Fingerprunk. Regex: {}",
@@ -254,3 +269,17 @@ impl Fingerprunk {
); );
} }
} }
fn create_sig_builder(
typ: SignatureType,
creation_time: SystemTime,
) -> Result<SignatureBuilder, anyhow::Error> {
SignatureBuilder::new(typ)
.set_signature_creation_time(creation_time)?
.set_hash_algo(HashAlgorithm::SHA512)
.set_preferred_hash_algorithms(vec![HashAlgorithm::SHA512, HashAlgorithm::SHA256])?
.set_preferred_symmetric_algorithms(vec![
SymmetricAlgorithm::AES256,
SymmetricAlgorithm::AES128,
])
}
+46 -11
View File
@@ -1,12 +1,13 @@
use std::{ use std::{
io::{self, IsTerminal}, io::{self, IsTerminal},
num::NonZeroU64, num::NonZero,
}; };
use anyhow::{Context as AnyhowContext, anyhow}; use anyhow::{Context, anyhow};
use clap::{ArgAction, Parser, ValueEnum}; use clap::{ArgAction, Parser, ValueEnum};
use fancy_regex::Regex; use fancy_regex::Regex;
use fingerprunk::Fingerprunk; use fingerprunk::Fingerprunk;
use sequoia_openpgp::packet::UserID;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(version, about, long_about = None)] #[command(version, about, long_about = None)]
@@ -30,15 +31,32 @@ struct Args {
status: StatusEnabled, status: StatusEnabled,
/// Stop once the specified number of matching keys has been found. /// Stop once the specified number of matching keys has been found.
#[arg(long)] #[arg(short = 'n', long, value_name = "NUM")]
stop_after: Option<NonZeroU64>, count: Option<NonZero<u64>>,
/// Prompt for a password and use it to encrypt found keys. /// Prompt for a password and use it to encrypt matching keys.
/// ///
/// By default, found keys are printed to stdout unencrypted. Use this if you actually plan to /// By default, found keys are printed to stdout unencrypted. Use this if you actually plan to
/// use generated keys. /// use generated keys.
#[arg(short, long, action = ArgAction::SetTrue)] #[arg(short, long, action = ArgAction::SetTrue)]
password: bool, password: bool,
/// Add the given user ID to matching keys.
#[arg(short, long = "userid")]
userid: Vec<UserID>,
/// Explicitly do not add user IDs to matching keys.
///
/// Disables the warning about importing keys without user IDs into GnuPG.
#[arg(long, conflicts_with = "userid", action = ArgAction::SetTrue)]
no_userid: bool,
/// Use the specified amount of worker threads.
///
/// If not specified, the amount of worker threads will be set to the amount of the machine's
/// available parallelism.
#[arg(long, value_name = "NUM")]
workers: Option<NonZero<usize>>,
} }
#[derive(ValueEnum, Clone, Copy, Debug, Default)] #[derive(ValueEnum, Clone, Copy, Debug, Default)]
@@ -62,16 +80,33 @@ impl StatusEnabled {
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
let args = Args::parse(); let args = Args::parse();
let workers = match args.workers {
Some(workers) => workers,
None => std::thread::available_parallelism().context(
"unable to determine available parallelism, \
use `--workers <NUM>` to specify amount of worker threads",
)?,
};
if !args.no_userid && args.userid.is_empty() {
eprintln!(
"WARNING: No user ID was provided.\n\
You may experience problems importing generated keys into GnuPG.\n\
Use `--userid <USERID>` to add a user ID.\n"
);
}
let password = if args.password { let password = if args.password {
let password = rpassword::prompt_password( let password = rpassword::prompt_password(
"Enter password for encrypting found keys (leave empty for no encryption): ", "Enter password for encrypting found keys (leave empty for no encryption): ",
) )
.with_context(|| "Failed to prompt password")?; .context("Failed to prompt password")?;
if password.is_empty() { if password.is_empty() {
None None
} else { } else {
let password_retype = rpassword::prompt_password("Retype password: ") let password_retype = rpassword::prompt_password("Retype password: ")
.with_context(|| "Failed to prompt password retype")?; .context("Failed to prompt password retype")?;
if password_retype == password { if password_retype == password {
Some(password.into()) Some(password.into())
} else { } else {
@@ -85,11 +120,11 @@ fn main() -> anyhow::Result<()> {
let config = fingerprunk::Config { let config = fingerprunk::Config {
regex: args.regex, regex: args.regex,
status_enabled: args.status.evaluate(), status_enabled: args.status.evaluate(),
stop_after: args.stop_after, count: args.count,
password, password,
userids: args.userid,
workers,
}; };
Fingerprunk::new_from_config(config).run(); Fingerprunk::new_from_config(config).run()
Ok(())
} }