Release new Rust brainwallet search tool

This commit is contained in:
Christian Reitter 2025-08-17 16:26:38 +02:00
parent af617807b9
commit 5650e731c5
7 changed files with 322 additions and 1 deletions

View File

@ -1,7 +1,7 @@
[package] [package]
name = "address_filter" name = "address_filter"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
publish = false publish = false
[dependencies] [dependencies]

View File

@ -0,0 +1 @@
pub mod bloom;

1
rust-brainwallet-search/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
Cargo.lock

View File

@ -0,0 +1,32 @@
[package]
name = "rust-brainwallet-search"
version = "0.1.0"
edition = "2024"
# not meant to be published as a crate
publish = false
[dependencies]
clap = { version = "4.5.40", features = ["derive"] }
rayon = "1.10.0"
bloomfilter = "1.0.14"
sha2 = { version = "0.10.9" }
csv = "1.3.1"
bitcoin = { version = "0.99.0", features = [
"std",
], default-features = false } # special handling, see below
# re-use existing custom bloom filter handling code
address_filter = { path = "../early_research_code/address_filter" }
# https://doc.rust-lang.org/cargo/reference/overriding-dependencies.html
[patch.crates-io]
# use custom unsafe fast version
bitcoin = { git = "https://git.distrust.co/milksad/rust-bitcoin-unsafe-fast" }
# use custom unsafe fast version
secp256k1 = { git = "https://git.distrust.co/milksad/rust-secp256k1-unsafe-fast" }
[profile.release]
# leads to faster + smaller binaries
panic = 'abort'

View File

@ -0,0 +1,37 @@
# Finding Weak "brain wallets"
This tool checks if any of the text snippets from the input were used as passphrases for a brain wallet. For now, this search is Bitcoin-only. It uses multi-threading by default.
The use of brain wallets was a dangerous practice in the first years of Bitcoin, and is extremely vulnerable to brute-forcing. See other code and documentation such as https://git.distrust.co/milksad/rust-bloom-filter-generator and https://milksad.info/posts/research-update-3/ on how to obtain the necessary Bitcoin address lists and generate the bloom filter that identifies used addresses.
## Usage
See the application `--help` output.
To manually override the number of parallel rayon threads, use the environment variable `RAYON_NUM_THREADS` such as `RAYON_NUM_THREADS=23`.
## Status
This repository contains unmaintained and unstable code which is meant for other researchers. We give no functionality or security guarantees.
Feedback is welcome, but we're unlikely to implement any feature requests.
## Research Ethics and Prior Work
This code finds weak private keys. The code author(s) ask you to remember that with great power comes great responsibility. See also [here](https://github.com/ryancdotorg/brainflayer?tab=readme-ov-file#disclaimer).
In-depth security research on this exact topic, such as [Ryan Castellucci's DEFCON23 talk](https://rya.nc/defcon-brainwallets.html) and [brainflayer](https://github.com/ryancdotorg/brainflayer) tool has been public and widely known for over **ten years** at this point. Clearly, more efficient and versatile tooling than our code was already published. We therefore think the educational and scientific benefits of making our code public outweigh the risks.
## Usage Warning
Parallel CPU brute forcing of hashes and cryptocurrency keys causes exceptionally high CPU load and high temperatures, which some systems may not be able to handle. Ensure that your machine can deal with such workloads, for example by running CPU-intensive benchmark tools for multiple hours while monitoring temperatures.
Use this code at your own risk. You've been warned.
## License
To be determined.
## Credits
Written by Christian Reitter.

View File

@ -0,0 +1,20 @@
use clap::{Parser, arg};
use rust_brainwallet_search::brainwallet_single_sha256_check_btc;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Opts {
#[arg(short, long, help = "Input file with newline-separated text snippet candidates")]
input_file: String,
#[arg(short, long, help = "Output file in CSV format with information on matches")]
output_file: String,
#[arg(long, help = "BTC Bloom filter file with known addresses")]
bloom_file: String,
}
fn main() {
let opts: Opts = Opts::parse();
brainwallet_single_sha256_check_btc(&opts.input_file, &opts.output_file, &opts.bloom_file);
}

View File

@ -0,0 +1,230 @@
use bitcoin::Address;
use bitcoin::CompressedPublicKey;
use bitcoin::PrivateKey;
use bitcoin::secp256k1::Secp256k1;
use bloomfilter::Bloom;
use rayon::prelude::*;
use sha2::Digest;
use sha2::Sha256;
use std::fs::File;
use std::io::BufRead;
use std::io::BufReader;
use std::sync::Mutex;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicUsize, Ordering};
static SECP_ENGINE: OnceLock<Secp256k1<bitcoin::secp256k1::All>> = OnceLock::new();
pub fn secp_engine<'a>() -> &'a Secp256k1<bitcoin::secp256k1::All> {
SECP_ENGINE.get_or_init(Secp256k1::new)
}
/// Mutex-protected counter with printing mechanism, usable in concurrent threads
struct Counter {
counter: AtomicUsize,
mask: usize,
}
impl Counter {
/// A log message is printed every time the counter bitwise-and comparison with `mask` is zero
fn new(mask: usize) -> Self {
Self {
counter: AtomicUsize::new(0),
mask,
}
}
/// Count lines and print progress based on the configured bit mask
fn count_and_print_regularly(&self) {
let cur = self.counter.fetch_add(1, Ordering::Relaxed);
if cur & self.mask == 0 {
println!("Processed {} lines", cur);
}
}
}
pub fn check_bloom_and_record_hits(
bloom: &Bloom<String>,
query: String,
writer: &Mutex<csv::Writer<std::fs::File>>,
source_id: String,
source_id2: String,
compressed_status: String,
prng_index: String,
prng_round_offset: String,
bit_length: String,
path: String,
print_hit: bool,
) {
if bloom.check(&query) {
if print_hit {
// quick visual representation of a match
// see actual data output for details
println!("hit: {}", query);
}
let mut wtr = writer.lock().unwrap();
wtr.write_record([
source_id,
source_id2,
compressed_status,
bit_length,
prng_index,
prng_round_offset,
path,
query,
])
.unwrap();
wtr.flush().expect("flush failed");
}
}
pub fn brainwallet_single_sha256_check_btc(
input_file: &String,
output_file: &String,
btc_bloom: &String,
) {
println!("Loading bloom filter dump ...");
let bloom = address_filter::bloom::load(std::path::Path::new(&btc_bloom.to_string()))
.expect("Couldn't load bloom filter dump");
println!("... done.");
let wtr = Mutex::new(csv::Writer::from_path(output_file).unwrap());
// log every 2^16 lines
let c = Counter::new(0b0000_1111_1111_1111_1111);
let secp = secp_engine();
let bitcoin_mainnet_constant = bitcoin::network::Network::Bitcoin;
let file = File::open(input_file).unwrap();
// TODO this intentionally aborts hard on any problematic line
// replace with a more robust handling
let passphrases: Vec<String> = BufReader::new(file)
.lines()
.map(|l| l.expect("Could not parse line"))
.collect();
let source_id = "brainwallet-single-sha256-direct";
passphrases.par_iter().for_each(|passphrase| {
// Count lines and print progress
c.count_and_print_regularly();
// create a Sha256 object
let mut hasher = Sha256::new();
// write input message
// TODO this approach of input-to-hashing processing essentially strips some
// non-printable characters, causing anomalies or missed hits if the input
// contains those.
//
// TODO consider a different data conversion approach
hasher.update(passphrase.clone());
// read hash digest and consume hasher
let entropy = hasher.finalize();
let secret_key = bitcoin::secp256k1::SecretKey::from_slice(&entropy[..]).unwrap();
// Convert SecretKey to PrivateKey
let privkey_compressed = PrivateKey {
compressed: true,
network: bitcoin::network::NetworkKind::Main,
inner: secret_key,
};
let privkey_uncompressed = PrivateKey {
compressed: false,
network: bitcoin::network::NetworkKind::Main,
inner: secret_key,
};
let pubkey_compressed = privkey_compressed.public_key(secp);
let pubkey_uncompressed = privkey_uncompressed.public_key(secp);
let pubkey_compressed_secondformat =
CompressedPublicKey::from_private_key(secp, privkey_compressed).unwrap();
// P2PKH with compressed pubkey is seen occasionally
let address_from_compressed_pubkey =
Address::p2pkh(&pubkey_compressed, bitcoin_mainnet_constant).to_string();
check_bloom_and_record_hits(
&bloom,
address_from_compressed_pubkey.to_string(),
&wtr,
source_id.to_string(),
"direct".to_string(),
"compressed".to_string(),
"".to_string(),
"".to_string(),
"256".to_string(),
passphrase.to_string(),
true,
);
// P2PKH with uncompressed pubkey is the most commonly seen result
let address_from_uncompressed_pubkey =
Address::p2pkh(&pubkey_uncompressed, bitcoin_mainnet_constant).to_string();
check_bloom_and_record_hits(
&bloom,
address_from_uncompressed_pubkey.to_string(),
&wtr,
source_id.to_string(),
"direct".to_string(),
"uncompressed".to_string(),
"".to_string(),
"".to_string(),
"256".to_string(),
passphrase.to_string(),
true,
);
// P2WPKH is rare, but seen in the wild
let address = Address::p2wpkh(
pubkey_compressed_secondformat,
bitcoin::network::Network::Bitcoin,
)
.to_string();
check_bloom_and_record_hits(
&bloom,
address.to_string(),
&wtr,
source_id.to_string(),
"direct".to_string(),
"compressed".to_string(),
"".to_string(),
"".to_string(),
"256".to_string(),
passphrase.to_string(),
true,
);
// P2SHWPKH is rare, but seen in the wild
let address = Address::p2shwpkh(
pubkey_compressed_secondformat,
bitcoin::network::Network::Bitcoin,
)
.to_string();
check_bloom_and_record_hits(
&bloom,
address.to_string(),
&wtr,
source_id.to_string(),
"direct".to_string(),
"compressed".to_string(),
"".to_string(),
"".to_string(),
"256".to_string(),
passphrase.to_string(),
true,
);
// intentionally don't check for P2TR format, it is unlikely to be used with brain wallets
// due to late introduction of the standard !?
});
// ensure flush
wtr.into_inner().unwrap().flush().unwrap();
}