21 Commits
Author SHA1 Message Date
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
nelsbrock 561a4d220b add --stop-after option 2025-10-25 13:53:38 +02:00
nelsbrock 6c7a0b7461 generate ECC keys instead of Ed25519 keys 2025-10-24 01:02:31 +02:00
nelsbrock 6620aa898d add armor comment to output certificates 2025-10-23 23:44:14 +02:00
nelsbrock bbc17385a7 fix README.md 2025-10-23 23:43:16 +02:00
5 changed files with 799 additions and 562 deletions
Generated
+551 -445
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.3.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.2.0"
sequoia-openpgp = "2.0.0"
+23 -22
View File
@@ -13,15 +13,16 @@ cargo install fingerprunk
## Usage ## Usage
Let's say you want to find keys whose fingerprints begin with `C0FFEE` and store them `secret.asc`. Let's say you want to find keys whose fingerprints begin with `C0FFEE` and store them
The regex for this is `^C0FFEE`. Now, simply use the following command to start the search: at `secret.asc`. The regex for this is `^C0FFEE`. Now, simply use the following command to start
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.
@@ -49,25 +50,25 @@ 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
43300 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:
| *n* | expected tries | expected time | | *n* | estimate tries | estimate time |
| --: | --------------: | ------------: | | --: | ---------------------: | ------------: |
| *n* | 16ⁿ | 43300s / 16ⁿ | | 1 | 16 = 16¹ | < 0.1 secs |
| 2 | 256 | 0.0059 secs | | 2 | 256 = 16² | < 0.1 secs |
| 3 | 4096 | 0.0946 secs | | 3 | 4096 = 16³ | 0.1 secs |
| 4 | 65536 | 1.5 secs | | 4 | 65536 = 16⁴ | 1.5 secs |
| 5 | 1028576 | 24 secs | | 5 | 1048576 = 16⁵ | 24 secs |
| 6 | 16777216 | 6.5 mins | | 6 | 16777216 = 16⁶ | 6 mins |
| 7 | 268435456 | 103 mins | | 7 | 268435456 = 16⁷ | 2 hours |
| 8 | 4294967296 | 27 hours | | 8 | 4294967296 = 16⁸ | 1 days |
| 9 | 68719476736 | 18 days | | 9 | 68719476736 = 16⁹ | 18 days |
| 10 | 1099511627776 | 293 days | | 10 | 1099511627776 = 16¹⁰ | 293 days |
| 11 | 17592186044416 | 13 years | | 11 | 17592186044416 = 16¹¹ | 13 years |
| 12 | 281474976710656 | 206 years | | 12 | 281474976710656 = 16¹² | 205 years |
As you can see, anything above 8 or 9 fixed digits is pretty much unfeasible, at least with As you can see, anything above 10 fixed digits is pretty much unfeasible, at least with a normal
a normal personal computer. personal computer.
+157 -70
View File
@@ -2,41 +2,53 @@
use std::{ use std::{
fmt::{self, Write}, fmt::{self, Write},
io::{self}, io,
num::NonZero,
sync::{ sync::{
atomic::{AtomicU64, Ordering}, atomic::{AtomicBool, AtomicU64, Ordering},
mpsc::{self, Receiver}, mpsc,
}, },
thread, thread,
time::{Duration, Instant}, time::{Duration, Instant, SystemTime},
}; };
use fancy_regex::Regex; use fancy_regex::Regex;
use num_integer::Integer; use num_integer::Integer;
use sequoia_openpgp::{ use sequoia_openpgp::{
Cert, Packet, Cert, Packet, armor,
crypto::Password, crypto::Password,
packet::{ packet::{
Key, Key, UserID,
key::{Key4, PrimaryRole, SecretParts}, key::{Key4, PrimaryRole, SecretParts},
prelude::SignatureBuilder, prelude::SignatureBuilder,
}, },
serialize::Serialize, serialize::Serialize,
types::{HashAlgorithm, SignatureType, SymmetricAlgorithm}, types::{Curve, HashAlgorithm, SignatureType, SymmetricAlgorithm},
}; };
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<NonZero<u64>>,
pub password: Option<Password>, pub password: Option<Password>,
pub userids: Vec<UserID>,
pub workers: NonZero<usize>,
} }
#[derive(Debug)] #[derive(Debug)]
pub struct Fingerprunk { pub struct Fingerprunk {
config: Config, config: Config,
started_instant: Instant,
stop: AtomicBool,
counter_tried: AtomicU64, counter_tried: AtomicU64,
counter_found: AtomicU64, counter_found: AtomicU64,
} }
@@ -52,54 +64,86 @@ impl Fingerprunk {
pub fn new_from_config(config: Config) -> Self { pub fn new_from_config(config: Config) -> Self {
Self { Self {
config, config,
started_instant: Instant::now(),
stop: AtomicBool::new(false),
counter_tried: AtomicU64::new(0), counter_tried: AtomicU64::new(0),
counter_found: AtomicU64::new(0), counter_found: AtomicU64::new(0),
} }
} }
pub fn run(self) { pub fn run(mut self) -> anyhow::Result<()> {
let (tx, rx) = mpsc::channel(); self.started_instant = Instant::now();
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 status_displayer = if self.config.status_enabled {
Some(
let ref_self = &self;
if self.config.status_enabled {
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 {
None
for num in 0..num_cpus::get() { };
let tx = tx.clone();
for num in 0..self.config.workers.get() {
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);
} }
thread::Builder::new() let mut stdout = io::stdout().lock();
.name("finalizer".to_string())
.spawn_scoped(scope, move || ref_self.finalizer_thread(rx)) // Receive and process messages from the workers and the ctrl-c handler
.expect(THREAD_SPAWN_EXPECT_MSG); for message in receiver {
}); 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.stop_after.is_some_and(|s| prev + 1 == s.get()) {
break;
}
}
Message::Stop => break,
}
} }
fn worker_thread(&self, matches_tx: mpsc::Sender<SecretKey>) { // Ask all other threads to stop
self.stop.store(true, Ordering::Relaxed);
// Unpark the status displayer thread, if existant
if let Some(status_displayer) = status_displayer {
status_displayer.thread().unpark();
}
Ok(())
})
}
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);
loop { while !self.stop.load(Ordering::Relaxed) {
let key = Key4::generate_ed25519().expect("should be able to generate key"); let key =
Key4::generate_ecc(true, Curve::Ed25519).expect("should be able to generate key");
fingerprint_hex.clear(); fingerprint_hex.clear();
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);
} }
@@ -113,51 +157,88 @@ impl Fingerprunk {
.expect("should check regex without error") .expect("should check regex without error")
} }
fn finalizer_thread(&self, matches_rx: Receiver<SecretKey>) { 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 result = self
.key_to_cert(&key)
.and_then(|cert| cert.as_tsk().armored().serialize(&mut stdout));
if let Err(err) = result {
eprintln!("Error: {err}");
} else {
self.counter_found.fetch_add(1, Ordering::Relaxed);
}
}
}
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<()> {
let mut comments = cert.armor_headers();
comments.push(format!(
"Generated with Fingerprunk. Regex: {}",
self.config.regex
));
let headers: Vec<_> = comments
.into_iter()
.map(|s| ("Comment".to_string(), s))
.collect();
let mut writer = armor::Writer::with_headers(to, armor::Kind::SecretKey, headers)?;
// Set the profile to RFC4880 because we generate v4 keys.
writer.set_profile(sequoia_openpgp::Profile::RFC4880)?;
cert.serialize(&mut writer)?;
writer.finalize()?;
Ok(())
} }
fn status_displayer_thread(&self) { fn status_displayer_thread(&self) {
const UPDATE_INTERVAL: Duration = Duration::from_millis(250);
eprint!("\n\n\n\n\n");
while !self.stop.load(Ordering::Relaxed) {
self.print_status();
// We are parking the thread instead of sleeping so we can unpark it when we want to
// stop the program.
thread::park_timeout(UPDATE_INTERVAL);
}
self.print_status();
}
fn print_status(&self) {
struct DurationDhms(Duration); struct DurationDhms(Duration);
impl fmt::Display for DurationDhms { impl fmt::Display for DurationDhms {
@@ -171,15 +252,9 @@ impl Fingerprunk {
} }
} }
const UPDATE_INTERVAL: Duration = Duration::from_millis(250);
const FORMAT_WIDTH: usize = 12; const FORMAT_WIDTH: usize = 12;
let start = Instant::now(); let duration = DurationDhms(self.started_instant.elapsed());
eprint!("\n\n\n\n\n");
loop {
let duration = DurationDhms(start.elapsed());
let keys = self.counter_tried.load(Ordering::Relaxed); let keys = self.counter_tried.load(Ordering::Relaxed);
let keys_per_sec = keys as f64 / duration.0.as_secs_f64(); let keys_per_sec = keys as f64 / duration.0.as_secs_f64();
let found = self.counter_found.load(Ordering::Relaxed); let found = self.counter_found.load(Ordering::Relaxed);
@@ -192,7 +267,19 @@ impl Fingerprunk {
Found: {found: >w$} keys\n", Found: {found: >w$} keys\n",
w = FORMAT_WIDTH w = FORMAT_WIDTH
); );
thread::sleep(UPDATE_INTERVAL);
} }
} }
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,
])
} }
+51 -8
View File
@@ -1,9 +1,13 @@
use std::io::{self, IsTerminal}; use std::{
io::{self, IsTerminal},
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)]
@@ -26,12 +30,33 @@ struct Args {
#[arg(long, value_enum, default_value_t)] #[arg(long, value_enum, default_value_t)]
status: StatusEnabled, status: StatusEnabled,
/// Prompt for a password and use it to encrypt found keys. /// Stop once the specified number of matching keys has been found.
#[arg(long, value_name = "NUM")]
stop_after: Option<NonZero<u64>>,
/// 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)]
@@ -55,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 {
@@ -78,10 +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,
password, password,
userids: args.userid,
workers,
}; };
Fingerprunk::new_from_config(config).run(); Fingerprunk::new_from_config(config).run()
Ok(())
} }