From 57050f4e33e240f85764958a08e532e4e72ed2e5 Mon Sep 17 00:00:00 2001 From: RecRanger <168371178+RecRanger@users.noreply.github.com> Date: Fri, 17 May 2024 20:49:57 -0600 Subject: [PATCH 01/14] Upgrade README --- README.md | 17 ++++++++++++----- keyhunter.py | 5 ++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index fea27e0..944ddbf 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,23 @@ keyhunter ========= -A tool to recover lost bitcoin private keys from dead harddrives. +A tool to recover lost bitcoin private keys from dead hard drives. -chmod +x keyhunter.py +## Usage -./keyhunter.py /dev/sdc +```bash +python3 keyhunter.py /dev/sdX -output should list found private keys in base58 key import format. +./keyhunter.py /dev/sdX +``` -bitcoind importprivkey 5K????????????? yay +The output should list found private keys, in base58 key import format. + +To import into bitcoind, use the following command: +```bash +bitcoind importprivkey 5K????????????? yay bitcoind getbalance +``` DONATIONS --> 1YAyBtCwvZqNF9umZTUmfQ6vvLQRTG9qG diff --git a/keyhunter.py b/keyhunter.py index 0e3b1e6..8f881cf 100755 --- a/keyhunter.py +++ b/keyhunter.py @@ -1,7 +1,6 @@ -#!/usr/bin/python +#!/usr/bin/env python3 + -import binascii -import os import hashlib import sys From d430d86826b7cf117ffc1e0fa3461771decb009a Mon Sep 17 00:00:00 2001 From: RecRanger <168371178+RecRanger@users.noreply.github.com> Date: Fri, 17 May 2024 20:55:09 -0600 Subject: [PATCH 02/14] Add generic Python gitignore --- .gitignore | 160 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68bc17f --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ From 6bf8e4f460291e888f6c8e456352b60ab569140f Mon Sep 17 00:00:00 2001 From: RecRanger <168371178+RecRanger@users.noreply.github.com> Date: Fri, 17 May 2024 20:55:27 -0600 Subject: [PATCH 03/14] Apply repo setup and autoformat --- .vscode/extensions.json | 7 +++++++ .vscode/settings.json | 12 ++++++++++++ keyhunter.py | 38 ++++++++++++++++++++------------------ 3 files changed, 39 insertions(+), 18 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..ae11968 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "ms-python.black-formatter", + "ms-python.flake8", + "ms-python.python" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..afe1151 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "editor.formatOnSave": true, + "editor.rulers": [ + 79 + ], + "flake8.args": [ + "--max-line-length=79" + ], + "black-formatter.args": [ + "--line-length=79" + ], +} \ No newline at end of file diff --git a/keyhunter.py b/keyhunter.py index 8f881cf..c851db1 100755 --- a/keyhunter.py +++ b/keyhunter.py @@ -4,27 +4,26 @@ import hashlib import sys -# bytes to read at a time from file (10meg) -readlength=10*1024*1024 +# bytes to read at a time from file (10 MiB) +readlength = 10 * 1024 * 1024 -magic = '\x01\x30\x82\x01\x13\x02\x01\x01\x04\x20' +magic = "\x01\x30\x82\x01\x13\x02\x01\x01\x04\x20" magiclen = len(magic) -##### start code from pywallet.py ############# - -__b58chars = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' +__b58chars = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" __b58base = len(__b58chars) + def b58encode(v): - """ encode v, which is a string of bytes, to base58. - """ + """encode v, which is a string of bytes, to base58.""" - long_value = 0L - for (i, c) in enumerate(v[::-1]): + # long_value = 0L + long_value = 0 + for i, c in enumerate(v[::-1]): long_value += (256**i) * ord(c) - result = '' + result = "" while long_value >= __b58base: div, mod = divmod(long_value, __b58base) result = __b58chars[mod] + result @@ -35,20 +34,21 @@ def b58encode(v): # leading 0-bytes in the input become leading-1s nPad = 0 for c in v: - if c != '\0': + if c != "\0": break nPad += 1 - return (__b58chars[0]*nPad) + result + return (__b58chars[0] * nPad) + result + def Hash(data): return hashlib.sha256(hashlib.sha256(data).digest()).digest() + def EncodeBase58Check(secret): hash = Hash(secret) return b58encode(secret + hash[0:4]) -########## end code from pywallet.py ############ def find_keys(filename): keys = set() @@ -67,7 +67,7 @@ def find_keys(filename): if pos == -1: break key_offset = pos + magiclen - key_data = "\x80" + data[key_offset:key_offset + 32] + key_data = "\x80" + data[key_offset : key_offset + 32] keys.add(EncodeBase58Check(key_data)) pos += 1 @@ -77,14 +77,16 @@ def find_keys(filename): f.seek(f.tell() - (32 + magiclen)) return keys + def main(): if len(sys.argv) != 2: - print "./{0} ".format(sys.argv[0]) + print("./{0} ".format(sys.argv[0])) exit() keys = find_keys(sys.argv[1]) for key in keys: - print key + print(key) + if __name__ == "__main__": - main() \ No newline at end of file + main() From 70782f611338708844b94d5beaceb2c368debfff Mon Sep 17 00:00:00 2001 From: RecRanger <168371178+RecRanger@users.noreply.github.com> Date: Fri, 17 May 2024 21:16:22 -0600 Subject: [PATCH 04/14] Refactor and improve style --- .vscode/settings.json | 6 +-- keyhunter.py | 116 +++++++++++++++++++++++++++++------------- 2 files changed, 85 insertions(+), 37 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index afe1151..37842bb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,12 +1,12 @@ { "editor.formatOnSave": true, "editor.rulers": [ - 79 + 99 ], "flake8.args": [ - "--max-line-length=79" + "--max-line-length=99" ], "black-formatter.args": [ - "--line-length=79" + "--line-length=99" ], } \ No newline at end of file diff --git a/keyhunter.py b/keyhunter.py index c851db1..28fa32d 100755 --- a/keyhunter.py +++ b/keyhunter.py @@ -1,18 +1,25 @@ #!/usr/bin/env python3 +import argparse import hashlib +import logging import sys +from typing import Optional +from pathlib import Path + +logger = logging.getLogger(__name__) + # bytes to read at a time from file (10 MiB) -readlength = 10 * 1024 * 1024 +READ_BLOCK_SIZE = 10 * 1024 * 1024 -magic = "\x01\x30\x82\x01\x13\x02\x01\x01\x04\x20" -magiclen = len(magic) +MAGIC_BYTES = b"\x01\x30\x82\x01\x13\x02\x01\x01\x04\x20" +MAGIC_BYTES_LEN = len(MAGIC_BYTES) -__b58chars = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" -__b58base = len(__b58chars) +B58_CHARS = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +B58_BASE = len(B58_CHARS) # literally 58 def b58encode(v): @@ -24,11 +31,11 @@ def b58encode(v): long_value += (256**i) * ord(c) result = "" - while long_value >= __b58base: - div, mod = divmod(long_value, __b58base) - result = __b58chars[mod] + result + while long_value >= B58_BASE: + div, mod = divmod(long_value, B58_BASE) + result = B58_CHARS[mod] + result long_value = div - result = __b58chars[long_value] + result + result = B58_CHARS[long_value] + result # Bitcoin does a little leading-zero-compression: # leading 0-bytes in the input become leading-1s @@ -38,7 +45,7 @@ def b58encode(v): break nPad += 1 - return (__b58chars[0] * nPad) + result + return (B58_CHARS[0] * nPad) + result def Hash(data): @@ -50,43 +57,84 @@ def EncodeBase58Check(secret): return b58encode(secret + hash[0:4]) -def find_keys(filename): +def find_keys(filename: str | Path) -> set[str]: keys = set() with open(filename, "rb") as f: - # read through target file one block at a time - while True: - data = f.read(readlength) - if not data: - break + logger.info(f"Opened file: {filename}") + # read through target file one block at a time + while data := f.read(READ_BLOCK_SIZE): # look in this block for keys - pos = 0 - while True: + pos = 0 # index in the block + while (pos := data.find(MAGIC_BYTES, pos)) > -1: # find the magic number - pos = data.find(magic, pos) - if pos == -1: - break - key_offset = pos + magiclen - key_data = "\x80" + data[key_offset : key_offset + 32] - keys.add(EncodeBase58Check(key_data)) + key_offset = pos + MAGIC_BYTES_LEN + key_data = "\x80" + data[key_offset : key_offset + 32] # noqa: E203 + priv_key_wif = EncodeBase58Check(key_data) + keys.add(priv_key_wif) + logger.info( + f"Found key at offset {key_offset:,} = 0x{key_offset:02x}: {priv_key_wif}" + ) pos += 1 # are we at the end of the file? - if len(data) == readlength: + if len(data) == READ_BLOCK_SIZE: + logger.info("At end of file. Seeking back 32 bytes.") # make sure we didn't miss any keys at the end of the block - f.seek(f.tell() - (32 + magiclen)) + f.seek(f.tell() - (32 + MAGIC_BYTES_LEN)) return keys -def main(): - if len(sys.argv) != 2: - print("./{0} ".format(sys.argv[0])) - exit() +def setup_logging(log_filename: Optional[str | Path] = None): + # Create a logger object + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) # Set the logging level + + # Create a console handler and set level to debug + console_handler = logging.StreamHandler(sys.stdout) # Using stdout instead of stderr + console_handler.setLevel(logging.DEBUG) + console_formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + console_handler.setFormatter(console_formatter) + logger.addHandler(console_handler) + + # Optionally add a file handler + if log_filename: + file_handler = logging.FileHandler(log_filename) + file_handler.setLevel(logging.DEBUG) + file_formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + file_handler.setFormatter(file_formatter) + logger.addHandler(file_handler) + + return logger + + +def main_keyhunter(haystack_filename: str | Path, log_path: Optional[str | Path] = None): + setup_logging(log_path) + logger.info("Starting keyhunter") + + keys = find_keys(haystack_filename) + + logger.info(f"Found {len(keys)} keys: {keys}") + + if len(keys) > 0: + logger.info("Keys (as base58 WIF private keys):") + for key in keys: + print(key) + + logger.info("Finished keyhunter") + + +def get_args(): + parser = argparse.ArgumentParser(description="Find Bitcoin private keys in a file.") + parser.add_argument("filename", help="The file to search for keys.") + parser.add_argument("--log", help="Log file to write logs to.") + return parser.parse_args() + - keys = find_keys(sys.argv[1]) - for key in keys: - print(key) +def main_cli(): + args = get_args() + main_keyhunter(args.filename, log_path=args.log) if __name__ == "__main__": - main() + main_cli() From 5789ca3eef9e398c90e094a0fb417cb04a718d7f Mon Sep 17 00:00:00 2001 From: RecRanger <168371178+RecRanger@users.noreply.github.com> Date: Fri, 17 May 2024 21:25:09 -0600 Subject: [PATCH 05/14] GPT conversion Py 2->3 --- .gitignore | 160 +++++++++++++++++++++++++++++++++++++++++++++++++++ keyhunter.py | 19 +++--- 2 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6769e21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ \ No newline at end of file diff --git a/keyhunter.py b/keyhunter.py index 0e3b1e6..0973cda 100755 --- a/keyhunter.py +++ b/keyhunter.py @@ -6,9 +6,9 @@ import sys # bytes to read at a time from file (10meg) -readlength=10*1024*1024 +readlength = 10 * 1024 * 1024 -magic = '\x01\x30\x82\x01\x13\x02\x01\x01\x04\x20' +magic = b'\x01\x30\x82\x01\x13\x02\x01\x01\x04\x20' magiclen = len(magic) @@ -21,9 +21,9 @@ def b58encode(v): """ encode v, which is a string of bytes, to base58. """ - long_value = 0L + long_value = 0 for (i, c) in enumerate(v[::-1]): - long_value += (256**i) * ord(c) + long_value += (256**i) * c result = '' while long_value >= __b58base: @@ -36,7 +36,7 @@ def b58encode(v): # leading 0-bytes in the input become leading-1s nPad = 0 for c in v: - if c != '\0': + if c != 0: break nPad += 1 @@ -68,7 +68,7 @@ def find_keys(filename): if pos == -1: break key_offset = pos + magiclen - key_data = "\x80" + data[key_offset:key_offset + 32] + key_data = b"\x80" + data[key_offset:key_offset + 32] keys.add(EncodeBase58Check(key_data)) pos += 1 @@ -80,12 +80,13 @@ def find_keys(filename): def main(): if len(sys.argv) != 2: - print "./{0} ".format(sys.argv[0]) + print(f"./{sys.argv[0]} ") exit() keys = find_keys(sys.argv[1]) + print(f"Found {len(keys)} keys in {sys.argv[1]}") for key in keys: - print key + print(key) if __name__ == "__main__": - main() \ No newline at end of file + main() From eaf2f1439a97fe8dbfdb3104da9fac9a5386669c Mon Sep 17 00:00:00 2001 From: RecRanger <168371178+RecRanger@users.noreply.github.com> Date: Fri, 17 May 2024 21:46:51 -0600 Subject: [PATCH 06/14] Add >2012 magic search key --- README.md | 6 ++--- keyhunter.py | 67 ++++++++++++++++++++++++++++++---------------------- 2 files changed, 42 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 944ddbf..701cc3e 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,16 @@ A tool to recover lost bitcoin private keys from dead hard drives. ```bash python3 keyhunter.py /dev/sdX - +# --or-- ./keyhunter.py /dev/sdX ``` The output should list found private keys, in base58 key import format. -To import into bitcoind, use the following command: +To import into bitcoind, use the following command for each key: ```bash -bitcoind importprivkey 5K????????????? yay +bitcoind importprivkey 5KXXXXXXXXXXXX bitcoind getbalance ``` diff --git a/keyhunter.py b/keyhunter.py index 9fed936..029566f 100755 --- a/keyhunter.py +++ b/keyhunter.py @@ -14,15 +14,19 @@ # bytes to read at a time from file (10 MiB) READ_BLOCK_SIZE = 10 * 1024 * 1024 -MAGIC_BYTES = b"\x01\x30\x82\x01\x13\x02\x01\x01\x04\x20" -MAGIC_BYTES_LEN = len(MAGIC_BYTES) +MAGIC_BYTES_LIST = [ + bytes.fromhex("01308201130201010420"), # old, <2012 + bytes.fromhex("01d63081d30201010420"), # new, >2012 +] +MAGIC_BYTES_LEN = 10 # length of each element in MAGIC_BYTES_LIST +assert all(len(magic_bytes) == MAGIC_BYTES_LEN for magic_bytes in MAGIC_BYTES_LIST) B58_CHARS = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" B58_BASE = len(B58_CHARS) # literally 58 -def b58encode(v): +def b58encode(v: bytes) -> str: """encode v, which is a string of bytes, to base58.""" long_value = 0 @@ -47,50 +51,57 @@ def b58encode(v): return (B58_CHARS[0] * nPad) + result -def Hash(data): +def sha256d_hash(data: bytes) -> bytes: return hashlib.sha256(hashlib.sha256(data).digest()).digest() -def EncodeBase58Check(secret): - hash = Hash(secret) +def encode_base58_check(secret: bytes) -> str: + hash = sha256d_hash(secret) return b58encode(secret + hash[0:4]) def find_keys(filename: str | Path) -> set[str]: + """Searches a file for Bitcoin private keys. + Returns a set of private keys as base58 WIF strings. + """ + keys = set() with open(filename, "rb") as f: logger.info(f"Opened file: {filename}") # read through target file one block at a time - while data := f.read(READ_BLOCK_SIZE): - # look in this block for keys - pos = 0 # index in the block - while (pos := data.find(MAGIC_BYTES, pos)) > -1: - # find the magic number - key_offset = pos + MAGIC_BYTES_LEN - key_data = "\x80" + data[key_offset : key_offset + 32] # noqa: E203 - priv_key_wif = EncodeBase58Check(key_data) - keys.add(priv_key_wif) - logger.info( - f"Found key at offset {key_offset:,} = 0x{key_offset:02x}: {priv_key_wif}" - ) - pos += 1 - - # are we at the end of the file? - if len(data) == READ_BLOCK_SIZE: - logger.info("At end of file. Seeking back 32 bytes.") - # make sure we didn't miss any keys at the end of the block + while block_bytes := f.read(READ_BLOCK_SIZE): + # look in this block for each key + for magic_bytes in MAGIC_BYTES_LIST: + pos = 0 # index in the block + while (pos := block_bytes.find(magic_bytes, pos)) > -1: + # find the magic number + key_offset = pos + MAGIC_BYTES_LEN + key_data = b"\x80" + block_bytes[key_offset : key_offset + 32] # noqa: E203 + priv_key_wif = encode_base58_check(key_data) + keys.add(priv_key_wif) + logger.info( + f"Found key at offset {key_offset:,} = 0x{key_offset:02x} " + f"(using magic bytes {magic_bytes.hex()}): {priv_key_wif}" + ) + pos += 1 + + # Make sure we didn't miss any keys at the end of the block. + # After scanning the block, seek back so that the next block includes the overlap. + if len(block_bytes) == READ_BLOCK_SIZE: f.seek(f.tell() - (32 + MAGIC_BYTES_LEN)) + + logger.info(f"Closed file: {filename}") return keys -def setup_logging(log_filename: Optional[str | Path] = None): +def setup_logging(log_filename: Optional[str | Path] = None) -> logging.Logger: # Create a logger object logger = logging.getLogger() logger.setLevel(logging.DEBUG) # Set the logging level # Create a console handler and set level to debug - console_handler = logging.StreamHandler(sys.stdout) # Using stdout instead of stderr + console_handler = logging.StreamHandler(sys.stderr) console_handler.setLevel(logging.DEBUG) console_formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") console_handler.setFormatter(console_formatter) @@ -116,11 +127,11 @@ def main_keyhunter(haystack_filename: str | Path, log_path: Optional[str | Path] logger.info(f"Found {len(keys)} keys: {keys}") if len(keys) > 0: - logger.info("Keys (as base58 WIF private keys):") + logger.info("Printing keys (as base58 WIF private keys) for easy copying:") for key in keys: print(key) - logger.info("Finished keyhunter") + logger.info(f"Finished keyhunter. Found {len(keys):,} keys.") def get_args(): From a46caa4c25c4b1c8e75cce32522815aa4243265f Mon Sep 17 00:00:00 2001 From: RecRanger <168371178+RecRanger@users.noreply.github.com> Date: Fri, 17 May 2024 21:55:22 -0600 Subject: [PATCH 07/14] Add small UX improvements --- keyhunter.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/keyhunter.py b/keyhunter.py index 029566f..e00a844 100755 --- a/keyhunter.py +++ b/keyhunter.py @@ -122,6 +122,14 @@ def main_keyhunter(haystack_filename: str | Path, log_path: Optional[str | Path] setup_logging(log_path) logger.info("Starting keyhunter") + if log_path: + logger.info(f"Logging to console, and file: {log_path}") + else: + logger.info("Logging to console only.") + + if not Path(haystack_filename).is_file(): + raise FileNotFoundError(f"File not found: {haystack_filename}") + keys = find_keys(haystack_filename) logger.info(f"Found {len(keys)} keys: {keys}") @@ -137,13 +145,13 @@ def main_keyhunter(haystack_filename: str | Path, log_path: Optional[str | Path] def get_args(): parser = argparse.ArgumentParser(description="Find Bitcoin private keys in a file.") parser.add_argument("filename", help="The file to search for keys.") - parser.add_argument("--log", help="Log file to write logs to.") + parser.add_argument("-l", "--log", dest="log_path", help="Log file to write logs to.") return parser.parse_args() def main_cli(): args = get_args() - main_keyhunter(args.filename, log_path=args.log) + main_keyhunter(args.filename, log_path=args.log_path) if __name__ == "__main__": From 61cf149317c9709b3ba8ca65055ec8920e4a7f6b Mon Sep 17 00:00:00 2001 From: RecRanger <168371178+RecRanger@users.noreply.github.com> Date: Fri, 17 May 2024 22:38:46 -0600 Subject: [PATCH 08/14] Fix offset logging, add README notes --- README.md | 5 +++++ keyhunter.py | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 701cc3e..9648fe3 100644 --- a/README.md +++ b/README.md @@ -20,4 +20,9 @@ bitcoind importprivkey 5KXXXXXXXXXXXX bitcoind getbalance ``` +## Features and Limitations +* Supports both pre-2012 and post-2012 wallet keys. +* Supports logging to a file. +* Cannot find encrypted wallets. + DONATIONS --> 1YAyBtCwvZqNF9umZTUmfQ6vvLQRTG9qG diff --git a/keyhunter.py b/keyhunter.py index e00a844..d9e3530 100755 --- a/keyhunter.py +++ b/keyhunter.py @@ -14,6 +14,7 @@ # bytes to read at a time from file (10 MiB) READ_BLOCK_SIZE = 10 * 1024 * 1024 +# magic bytes reference: https://bitcointalk.org/index.php?topic=2745783.msg28084524#msg28084524 MAGIC_BYTES_LIST = [ bytes.fromhex("01308201130201010420"), # old, <2012 bytes.fromhex("01d63081d30201010420"), # new, >2012 @@ -80,8 +81,9 @@ def find_keys(filename: str | Path) -> set[str]: key_data = b"\x80" + block_bytes[key_offset : key_offset + 32] # noqa: E203 priv_key_wif = encode_base58_check(key_data) keys.add(priv_key_wif) + global_offset = f.tell() - len(block_bytes) + key_offset logger.info( - f"Found key at offset {key_offset:,} = 0x{key_offset:02x} " + f"Found key at offset {global_offset:,} = 0x{global_offset:_x} " f"(using magic bytes {magic_bytes.hex()}): {priv_key_wif}" ) pos += 1 @@ -132,6 +134,7 @@ def main_keyhunter(haystack_filename: str | Path, log_path: Optional[str | Path] keys = find_keys(haystack_filename) + keys = sorted(list(keys)) logger.info(f"Found {len(keys)} keys: {keys}") if len(keys) > 0: From 5b964fc39e440b869d57255c4f5afb2a74e4cbda Mon Sep 17 00:00:00 2001 From: RecRanger <168371178+RecRanger@users.noreply.github.com> Date: Sat, 18 May 2024 18:58:01 -0600 Subject: [PATCH 09/14] Improve logging --- .gitignore | 159 --------------------------------------------------- keyhunter.py | 10 +++- 2 files changed, 8 insertions(+), 161 deletions(-) diff --git a/.gitignore b/.gitignore index 896dfdb..68bc17f 100644 --- a/.gitignore +++ b/.gitignore @@ -152,165 +152,6 @@ dmypy.json # Cython debug symbols cython_debug/ -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore diff --git a/keyhunter.py b/keyhunter.py index d9e3530..c02d68b 100755 --- a/keyhunter.py +++ b/keyhunter.py @@ -67,6 +67,7 @@ def find_keys(filename: str | Path) -> set[str]: """ keys = set() + key_count = 0 with open(filename, "rb") as f: logger.info(f"Opened file: {filename}") @@ -80,11 +81,16 @@ def find_keys(filename: str | Path) -> set[str]: key_offset = pos + MAGIC_BYTES_LEN key_data = b"\x80" + block_bytes[key_offset : key_offset + 32] # noqa: E203 priv_key_wif = encode_base58_check(key_data) + is_new_key = priv_key_wif not in keys + key_count += 1 keys.add(priv_key_wif) global_offset = f.tell() - len(block_bytes) + key_offset + logger.info( - f"Found key at offset {global_offset:,} = 0x{global_offset:_x} " - f"(using magic bytes {magic_bytes.hex()}): {priv_key_wif}" + f"Found {('new key' if is_new_key else 'key again')} " + f"at offset {global_offset:,} = 0x{global_offset:_x} " + f"(using magic bytes {magic_bytes.hex()}): {priv_key_wif} " + f"({key_count:,} keys total, {len(keys):,} unique keys" ) pos += 1 From 0e1a9e8cc0582d336c319905b214a31995b7cee3 Mon Sep 17 00:00:00 2001 From: RecRanger <168371178+RecRanger@users.noreply.github.com> Date: Wed, 22 May 2024 18:15:18 -0600 Subject: [PATCH 10/14] Upgrade CLI interface --- README.md | 4 ++-- keyhunter.py | 39 ++++++++++++++++++++++++++++++++------- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9648fe3..d22b391 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ A tool to recover lost bitcoin private keys from dead hard drives. ## Usage ```bash -python3 keyhunter.py /dev/sdX +python3 keyhunter.py -i /dev/sdX --log ./sdX_log.log -o ./sdX_found_keys_list.txt # --or-- -./keyhunter.py /dev/sdX +./keyhunter.py -i /dev/sdX --log ./sdX_log.log -o ./sdX_found_keys_list.txt ``` The output should list found private keys, in base58 key import format. diff --git a/keyhunter.py b/keyhunter.py index c02d68b..77b04ac 100755 --- a/keyhunter.py +++ b/keyhunter.py @@ -90,7 +90,7 @@ def find_keys(filename: str | Path) -> set[str]: f"Found {('new key' if is_new_key else 'key again')} " f"at offset {global_offset:,} = 0x{global_offset:_x} " f"(using magic bytes {magic_bytes.hex()}): {priv_key_wif} " - f"({key_count:,} keys total, {len(keys):,} unique keys" + f"({key_count:,} keys total, {len(keys):,} unique keys)" ) pos += 1 @@ -126,7 +126,11 @@ def setup_logging(log_filename: Optional[str | Path] = None) -> logging.Logger: return logger -def main_keyhunter(haystack_filename: str | Path, log_path: Optional[str | Path] = None): +def main_keyhunter( + haystack_file_path: str | Path, + log_path: Optional[str | Path] = None, + output_keys_file_path: Optional[str | Path] = None, +): setup_logging(log_path) logger.info("Starting keyhunter") @@ -135,10 +139,10 @@ def main_keyhunter(haystack_filename: str | Path, log_path: Optional[str | Path] else: logger.info("Logging to console only.") - if not Path(haystack_filename).is_file(): - raise FileNotFoundError(f"File not found: {haystack_filename}") + if not Path(haystack_file_path).is_file(): + raise FileNotFoundError(f"File not found: {haystack_file_path}") - keys = find_keys(haystack_filename) + keys = find_keys(haystack_file_path) keys = sorted(list(keys)) logger.info(f"Found {len(keys)} keys: {keys}") @@ -148,19 +152,40 @@ def main_keyhunter(haystack_filename: str | Path, log_path: Optional[str | Path] for key in keys: print(key) + if output_keys_file_path: + with open(output_keys_file_path, "w") as f: + for key in keys: + f.write(key + "\n") + logger.info(f"Finished keyhunter. Found {len(keys):,} keys.") def get_args(): parser = argparse.ArgumentParser(description="Find Bitcoin private keys in a file.") - parser.add_argument("filename", help="The file to search for keys.") + parser.add_argument( + "-i", + "--input", + required=True, + dest="input_file_path", + help="The input file (disk image, corrupt wallet.dat, etc.) to search for keys.", + ) parser.add_argument("-l", "--log", dest="log_path", help="Log file to write logs to.") + parser.add_argument( + "-o", + "--output", + dest="output_file_path", + help="Output file to write the WIF write keys to.", + ) return parser.parse_args() def main_cli(): args = get_args() - main_keyhunter(args.filename, log_path=args.log_path) + main_keyhunter( + args.input_file_path, + log_path=args.log_path, + output_keys_file_path=args.output_file_path, + ) if __name__ == "__main__": From 5ba02b17574d81c475162697e5c7139e51d88721 Mon Sep 17 00:00:00 2001 From: RecRanger <168371178+RecRanger@users.noreply.github.com> Date: Wed, 22 May 2024 19:48:03 -0600 Subject: [PATCH 11/14] Fix file-exists check for block devices --- keyhunter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keyhunter.py b/keyhunter.py index 77b04ac..09f0a39 100755 --- a/keyhunter.py +++ b/keyhunter.py @@ -139,7 +139,7 @@ def main_keyhunter( else: logger.info("Logging to console only.") - if not Path(haystack_file_path).is_file(): + if not Path(haystack_file_path).exists(): raise FileNotFoundError(f"File not found: {haystack_file_path}") keys = find_keys(haystack_file_path) From bf6d5dc823122327d479d2619938dc63571170ba Mon Sep 17 00:00:00 2001 From: RecRanger <168371178+RecRanger@users.noreply.github.com> Date: Sun, 25 Jan 2026 06:59:06 -0700 Subject: [PATCH 12/14] Refactor into proper pyproject setup --- .vscode/extensions.json | 7 - .vscode/settings.json | 13 +- pyproject.toml | 33 ++++ src/keyhunter/__init__.py | 4 + src/keyhunter/__main__.py | 4 + keyhunter.py => src/keyhunter/keyhunter.py | 106 ++++++------- uv.lock | 168 +++++++++++++++++++++ 7 files changed, 255 insertions(+), 80 deletions(-) delete mode 100644 .vscode/extensions.json create mode 100644 pyproject.toml create mode 100644 src/keyhunter/__init__.py create mode 100644 src/keyhunter/__main__.py rename keyhunter.py => src/keyhunter/keyhunter.py (60%) mode change 100755 => 100644 create mode 100644 uv.lock diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index ae11968..0000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "recommendations": [ - "ms-python.black-formatter", - "ms-python.flake8", - "ms-python.python" - ] -} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 37842bb..8f6b93c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,12 +1,5 @@ { - "editor.formatOnSave": true, - "editor.rulers": [ - 99 - ], - "flake8.args": [ - "--max-line-length=99" - ], - "black-formatter.args": [ - "--line-length=99" - ], + "[python]": { + "editor.formatOnSave": true, + } } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..350e399 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "keyhunter" +version = "0.1.0" +description = "A tool to recover lost bitcoin private keys from dead hard drives." +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "loguru>=0.7.3", +] + +[dependency-groups] +dev = [ + "pyright>=1.1.408", + "pytest>=9.0.2", + "ruff>=0.14.14", +] + +[project.urls] +Homepage = "https://github.com/RecRanger/keyhunter" +Issues = "https://github.com/RecRanger/keyhunter/issues" + +[project.scripts] +keyhunter = "keyhunter.keyhunter:main_cli" + +[tool.pyright] +typeCheckingMode = "standard" + +[tool.ruff.lint] +select = ["ALL"] +ignore = ["D", "S", "TD", "FIX", "COM812"] + +[tool.uv] +package = true diff --git a/src/keyhunter/__init__.py b/src/keyhunter/__init__.py new file mode 100644 index 0000000..286de97 --- /dev/null +++ b/src/keyhunter/__init__.py @@ -0,0 +1,4 @@ +from keyhunter import keyhunter + +if __name__ == "__main__": + keyhunter.main_cli() diff --git a/src/keyhunter/__main__.py b/src/keyhunter/__main__.py new file mode 100644 index 0000000..286de97 --- /dev/null +++ b/src/keyhunter/__main__.py @@ -0,0 +1,4 @@ +from keyhunter import keyhunter + +if __name__ == "__main__": + keyhunter.main_cli() diff --git a/keyhunter.py b/src/keyhunter/keyhunter.py old mode 100755 new mode 100644 similarity index 60% rename from keyhunter.py rename to src/keyhunter/keyhunter.py index 09f0a39..2cc4de2 --- a/keyhunter.py +++ b/src/keyhunter/keyhunter.py @@ -1,25 +1,20 @@ -#!/usr/bin/env python3 - +"""A tool to recover lost bitcoin private keys from dead hard drives.""" import argparse import hashlib -import logging -import sys -from typing import Optional from pathlib import Path -logger = logging.getLogger(__name__) - +from loguru import logger -# bytes to read at a time from file (10 MiB) +# Bytes to read at a time from file (10 MiB). READ_BLOCK_SIZE = 10 * 1024 * 1024 -# magic bytes reference: https://bitcointalk.org/index.php?topic=2745783.msg28084524#msg28084524 +# Magic bytes reference: https://bitcointalk.org/index.php?topic=2745783.msg28084524#msg28084524 MAGIC_BYTES_LIST = [ - bytes.fromhex("01308201130201010420"), # old, <2012 - bytes.fromhex("01d63081d30201010420"), # new, >2012 + bytes.fromhex("01308201130201010420"), # Old (uncompressed), <2012 + bytes.fromhex("01d63081d30201010420"), # New (compressed), >2012 ] -MAGIC_BYTES_LEN = 10 # length of each element in MAGIC_BYTES_LIST +MAGIC_BYTES_LEN = 10 # Length of each element in MAGIC_BYTES_LIST. assert all(len(magic_bytes) == MAGIC_BYTES_LEN for magic_bytes in MAGIC_BYTES_LIST) @@ -28,7 +23,7 @@ def b58encode(v: bytes) -> str: - """encode v, which is a string of bytes, to base58.""" + """Encode v, which is a string of bytes, to base58.""" long_value = 0 for i, c in enumerate(v[::-1]): @@ -43,13 +38,13 @@ def b58encode(v: bytes) -> str: # Bitcoin does a little leading-zero-compression: # leading 0-bytes in the input become leading-1s - nPad = 0 + n_pad = 0 for c in v: if c != 0: break - nPad += 1 + n_pad += 1 - return (B58_CHARS[0] * nPad) + result + return (B58_CHARS[0] * n_pad) + result def sha256d_hash(data: bytes) -> bytes: @@ -57,29 +52,29 @@ def sha256d_hash(data: bytes) -> bytes: def encode_base58_check(secret: bytes) -> str: - hash = sha256d_hash(secret) - return b58encode(secret + hash[0:4]) + hash_val = sha256d_hash(secret) + return b58encode(secret + hash_val[0:4]) -def find_keys(filename: str | Path) -> set[str]: +def find_keys(filename: Path) -> set[str]: """Searches a file for Bitcoin private keys. Returns a set of private keys as base58 WIF strings. """ keys = set() key_count = 0 - with open(filename, "rb") as f: + with filename.open("rb") as f: logger.info(f"Opened file: {filename}") - # read through target file one block at a time + # Read through target file one block at a time. while block_bytes := f.read(READ_BLOCK_SIZE): - # look in this block for each key + # Look in this block for each key. for magic_bytes in MAGIC_BYTES_LIST: - pos = 0 # index in the block + pos = 0 # Index in the block. while (pos := block_bytes.find(magic_bytes, pos)) > -1: - # find the magic number + # Find the magic number. key_offset = pos + MAGIC_BYTES_LEN - key_data = b"\x80" + block_bytes[key_offset : key_offset + 32] # noqa: E203 + key_data = b"\x80" + block_bytes[key_offset : key_offset + 32] priv_key_wif = encode_base58_check(key_data) is_new_key = priv_key_wif not in keys key_count += 1 @@ -95,7 +90,8 @@ def find_keys(filename: str | Path) -> set[str]: pos += 1 # Make sure we didn't miss any keys at the end of the block. - # After scanning the block, seek back so that the next block includes the overlap. + # After scanning the block, seek back so that the next block includes the + # overlap. if len(block_bytes) == READ_BLOCK_SIZE: f.seek(f.tell() - (32 + MAGIC_BYTES_LEN)) @@ -103,35 +99,14 @@ def find_keys(filename: str | Path) -> set[str]: return keys -def setup_logging(log_filename: Optional[str | Path] = None) -> logging.Logger: - # Create a logger object - logger = logging.getLogger() - logger.setLevel(logging.DEBUG) # Set the logging level - - # Create a console handler and set level to debug - console_handler = logging.StreamHandler(sys.stderr) - console_handler.setLevel(logging.DEBUG) - console_formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") - console_handler.setFormatter(console_formatter) - logger.addHandler(console_handler) - - # Optionally add a file handler - if log_filename: - file_handler = logging.FileHandler(log_filename) - file_handler.setLevel(logging.DEBUG) - file_formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") - file_handler.setFormatter(file_formatter) - logger.addHandler(file_handler) - - return logger - - def main_keyhunter( - haystack_file_path: str | Path, - log_path: Optional[str | Path] = None, - output_keys_file_path: Optional[str | Path] = None, -): - setup_logging(log_path) + haystack_file_path: Path, + log_path: Path | None = None, + output_keys_file_path: Path | None = None, +) -> None: + if log_path: + logger.add(log_path) + logger.info("Starting keyhunter") if log_path: @@ -140,36 +115,41 @@ def main_keyhunter( logger.info("Logging to console only.") if not Path(haystack_file_path).exists(): - raise FileNotFoundError(f"File not found: {haystack_file_path}") + msg = f"File not found: {haystack_file_path}" + raise FileNotFoundError(msg) keys = find_keys(haystack_file_path) - keys = sorted(list(keys)) + keys = sorted(keys) logger.info(f"Found {len(keys)} keys: {keys}") if len(keys) > 0: logger.info("Printing keys (as base58 WIF private keys) for easy copying:") for key in keys: - print(key) + print(key) # noqa: T201 if output_keys_file_path: - with open(output_keys_file_path, "w") as f: + with output_keys_file_path.open("w") as f: for key in keys: f.write(key + "\n") logger.info(f"Finished keyhunter. Found {len(keys):,} keys.") -def get_args(): +def get_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Find Bitcoin private keys in a file.") parser.add_argument( "-i", "--input", required=True, dest="input_file_path", - help="The input file (disk image, corrupt wallet.dat, etc.) to search for keys.", + help=( + "The input file (disk image, corrupt wallet.dat, etc.) to search for keys." + ), + ) + parser.add_argument( + "-l", "--log", dest="log_path", help="Log file to write logs to." ) - parser.add_argument("-l", "--log", dest="log_path", help="Log file to write logs to.") parser.add_argument( "-o", "--output", @@ -179,10 +159,10 @@ def get_args(): return parser.parse_args() -def main_cli(): +def main_cli() -> None: args = get_args() main_keyhunter( - args.input_file_path, + haystack_file_path=args.input_file_path, log_path=args.log_path, output_keys_file_path=args.output_file_path, ) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0a4a3af --- /dev/null +++ b/uv.lock @@ -0,0 +1,168 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "keyhunter" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "loguru" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "loguru", specifier = ">=0.7.3" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.408" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "ruff", specifier = ">=0.14.14" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] From e7e12a0d939ab3281d14551ef116362a3e66d25d Mon Sep 17 00:00:00 2001 From: RecRanger <168371178+RecRanger@users.noreply.github.com> Date: Sun, 25 Jan 2026 07:00:40 -0700 Subject: [PATCH 13/14] Add basic CI for checking and publishing --- .github/workflows/main.yml | 58 +++++++++++++++++++++++++++++++++++ .github/workflows/publish.yml | 41 +++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 .github/workflows/main.yml create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..e593893 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + # Use ["main", "master"] for CI only on the default branch. + # Use ["**"] for CI on all branches. + branches: ["main", "master"] + pull_request: + branches: ["main", "master"] + workflow_dispatch: # Enable manual trigger. + +permissions: + contents: read + +jobs: + build: + strategy: + matrix: + os: ["ubuntu-latest", "macos-latest", "windows-latest"] + python-version: ["3.12", "3.13"] + + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + + steps: + # Generally following uv docs: + # https://docs.astral.sh/uv/guides/integration/github/ + + - name: Checkout (official GitHub action) + uses: actions/checkout@v5 + with: + # Important for versioning plugins: + fetch-depth: 0 + + - name: Install uv (official Astral action) + uses: astral-sh/setup-uv@v5 + with: + # Update this as needed: + version: "0.9.5" + enable-cache: true + python-version: ${{ matrix.python-version }} + + - name: Set up Python (using uv) + run: uv python install + + - name: Install all dependencies + run: uv sync --all-extras + + - name: Run linting + run: | + uv run ruff check . + uv run ruff format --check . + + - name: Run type checking + run: uv run pyright + + - name: Run tests + run: uv run pytest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..6f25230 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,41 @@ +name: Publish to PyPI + +on: + release: + types: [published] + workflow_dispatch: # Enable manual trigger. + +jobs: + build-and-publish: + runs-on: ubuntu-latest + permissions: + id-token: write # Mandatory for OIDC. + contents: read + steps: + - name: Checkout (official GitHub action) + uses: actions/checkout@v4 + with: + # Important for versioning plugins: + fetch-depth: 0 + + - name: Install uv (official Astral action) + uses: astral-sh/setup-uv@v5 + with: + version: "0.9.5" + enable-cache: true + python-version: "3.12" + + - name: Set up Python (using uv) + run: uv python install + + - name: Install all dependencies + run: uv sync --all-extras + + - name: Run tests + run: uv run pytest + + - name: Build package + run: uv build + + - name: Publish to PyPI + run: uv publish --trusted-publishing always From a40302b06f4cb9c3f81466f07eeeae9ff289774a Mon Sep 17 00:00:00 2001 From: RecRanger <168371178+RecRanger@users.noreply.github.com> Date: Sun, 25 Jan 2026 07:08:19 -0700 Subject: [PATCH 14/14] Add unit tests --- .vscode/settings.json | 7 ++++++- README.md | 12 ++++++------ src/keyhunter/keyhunter.py | 3 ++- tests/__init__.py | 1 + tests/test_keyhunter.py | 8 ++++++++ 5 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_keyhunter.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 8f6b93c..fca2e9b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,10 @@ { "[python]": { "editor.formatOnSave": true, - } + }, + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true } \ No newline at end of file diff --git a/README.md b/README.md index d22b391..0351f21 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -keyhunter -========= +# keyhunter A tool to recover lost bitcoin private keys from dead hard drives. @@ -7,11 +6,9 @@ A tool to recover lost bitcoin private keys from dead hard drives. ```bash python3 keyhunter.py -i /dev/sdX --log ./sdX_log.log -o ./sdX_found_keys_list.txt -# --or-- -./keyhunter.py -i /dev/sdX --log ./sdX_log.log -o ./sdX_found_keys_list.txt ``` -The output should list found private keys, in base58 key import format. +The output file lists found private keys, in base58 key WIF (wallet import format). To import into bitcoind, use the following command for each key: @@ -21,8 +18,11 @@ bitcoind getbalance ``` ## Features and Limitations + * Supports both pre-2012 and post-2012 wallet keys. * Supports logging to a file. * Cannot find encrypted wallets. -DONATIONS --> 1YAyBtCwvZqNF9umZTUmfQ6vvLQRTG9qG +## Credits + +This project is a maintained fork of [pierce403/keyhunter](https://github.com/pierce403/keyhunter). diff --git a/src/keyhunter/keyhunter.py b/src/keyhunter/keyhunter.py index 2cc4de2..5fa049d 100644 --- a/src/keyhunter/keyhunter.py +++ b/src/keyhunter/keyhunter.py @@ -19,7 +19,7 @@ B58_CHARS = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" -B58_BASE = len(B58_CHARS) # literally 58 +B58_BASE = len(B58_CHARS) # Literally 58. def b58encode(v: bytes) -> str: @@ -58,6 +58,7 @@ def encode_base58_check(secret: bytes) -> str: def find_keys(filename: Path) -> set[str]: """Searches a file for Bitcoin private keys. + Returns a set of private keys as base58 WIF strings. """ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..39c80a2 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for keyhunter module.""" diff --git a/tests/test_keyhunter.py b/tests/test_keyhunter.py new file mode 100644 index 0000000..f5bea79 --- /dev/null +++ b/tests/test_keyhunter.py @@ -0,0 +1,8 @@ +from keyhunter.keyhunter import b58encode + + +def test_b58encode() -> None: + # Generate cases: https://learnmeabitcoin.com/technical/keys/base58/ + assert b58encode(b"\x00\x00\x00\x01") == "1112" + assert b58encode(b"\x05\xab\xcd") == "2uUx" + assert b58encode(b"\x10\xff\x00\xab\xbc") == "2vDZMDM"