forked from madacol/btcrecover
-
Notifications
You must be signed in to change notification settings - Fork 275
Expand file tree
/
Copy pathwalletfinder.py
More file actions
1917 lines (1599 loc) · 77.3 KB
/
Copy pathwalletfinder.py
File metadata and controls
1917 lines (1599 loc) · 77.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# walletfinder.py -- Scan directories for supported wallet files and mnemonic phrases
# Copyright (C) 2014-2017 Christopher Gurnee
#
# This program is free software: you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version
# 2 of the License, or (at your option) or later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see http://www.gnu.org/licenses/
import compatibility_check
import argparse
import fnmatch
import hashlib
import os
import re
import signal
import sys
import threading
from pathlib import Path
from btcrecover.btcrpass import load_wallet, MAX_WALLET_FILE_SIZE
# ---------------------------------------------------------------------------
# Graceful shutdown flag
# ---------------------------------------------------------------------------
_should_stop = threading.Event()
def _handle_sigint(signum, frame):
"""Handle Ctrl+C by setting the stop event."""
_should_stop.set()
signal.signal(signal.SIGINT, _handle_sigint)
EXCLUDED_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', '.mypy_cache', '.pytest_cache'}
# OS pseudo/hardware filesystems that are never worth scanning and can hang or loop the walk.
# Matched by *absolute* path (see _is_system_dir), so a project folder that merely happens to
# be named 'dev' or 'run' under the scan root is unaffected. Windows problem folders are
# already handled by the per-operation timeouts.
if sys.platform.startswith('linux'):
# Device nodes and kernel interfaces: reading them can block indefinitely (tty devices)
# or expose enormous pseudo-files (/proc/kcore).
SYSTEM_EXCLUDED_DIRS = frozenset({'/proc', '/sys', '/dev', '/run'})
elif sys.platform == 'darwin':
# /dev devices; /System is the sealed OS volume whose Data firmlink would double-scan
# everything already reachable via /Users etc.; /private/var/vm is swap.
SYSTEM_EXCLUDED_DIRS = frozenset({'/dev', '/System', '/private/var/vm'})
else:
SYSTEM_EXCLUDED_DIRS = frozenset()
def _is_system_dir(path):
"""True if path is (or is inside) an OS pseudo/hardware filesystem never worth scanning."""
if not SYSTEM_EXCLUDED_DIRS:
return False
p = os.path.abspath(str(path))
return any(p == d or p.startswith(d + os.sep) for d in SYSTEM_EXCLUDED_DIRS)
def _is_symlink(path):
"""Return True if `path` is a symbolic link, False on any error.
Used to avoid descending into symlinked directories while walking. Following them
risks infinite loops (e.g. Linux ``/proc/<pid>/root`` points back at ``/``) and
double-scanning; like ``os.walk(followlinks=False)`` we do not recurse through them.
"""
try:
return os.path.islink(str(path))
except OSError:
return False
# Path-pattern exclusion list (bundled with BTCRecover) used to skip common false-positive
# system/application files as well as the repository's own test wallets and example seed/key
# files when scanning the repo (e.g. `--folder .`). See load_exclusions() and
# update_exclusion_list().
EXCLUSIONLIST_FILENAME = 'walletfinder-exclusionlist.txt'
# Marker line inside the exclusion list: everything above it is hand-curated and preserved
# verbatim by --update-exclusions; only the (repo-relative) entries below it are regenerated.
EXCLUSIONLIST_AUTO_MARKER = ("# --- Entries below this marker are managed by "
"`python walletfinder.py --update-exclusions`. ---")
# Text-mode file-size limits. Plain files are read directly, so a small cap avoids scanning
# large logs/data. Documents (pdf, docx, xlsx, ...) are extracted with textract: the file can be
# far larger than the little text it contains (e.g. a ~190 KB PDF paper wallet), so they get a
# more generous cap. Extracted text is still truncated to MAX_MNEMONIC_FILE_SIZE for scanning.
MAX_MNEMONIC_FILE_SIZE = 16 * 1024
MAX_DOCUMENT_FILE_SIZE = 500 * 1024
# File extensions that textract can extract text from (without leading dot)
TEXTRACT_SUPPORTED_EXTENSIONS = {
'csv', 'tsv', 'tab', 'doc', 'docx', 'eml', 'epub', 'gif',
'jpg', 'jpeg', 'json', 'html', 'htm', 'mp3', 'msg', 'odt',
'ogg', 'pdf', 'png', 'pptx', 'ps', 'rtf', 'tiff', 'tif', 'txt', 'wav',
'xls', 'xlsx',
}
# Cache for textract import (only attempt once)
_textract_module = None
TEXTRACT_AVAILABLE = False
# Warn at most once when a PDF needs the pypdf fallback but pypdf isn't installed
_pypdf_warning_shown = False
def _try_import_textract():
"""Lazily import textract, caching the result."""
global _textract_module, TEXTRACT_AVAILABLE
if _textract_module is not None:
return _textract_module
try:
import textract as _t
_textract_module = _t
TEXTRACT_AVAILABLE = True
except ImportError:
_textract_module = False
return _textract_module
def _is_pypdf_available():
"""Return True if pypdf is installed (used for the PDF custom-font-encoding fallback)."""
try:
import pypdf # noqa: F401
return True
except ImportError:
return False
def _extract_pdf_text_pypdf(filepath):
"""Extract text from a PDF using pypdf (handles custom font encodings better than pdfminer)."""
global _pypdf_warning_shown
try:
import pypdf
except ImportError:
if not _pypdf_warning_shown:
_pypdf_warning_shown = True
print("[WARNING] A PDF needed pypdf to extract its text (textract missing or produced "
"garbled output), but pypdf is not installed, so its text could not be scanned.")
print(" Install pypdf to scan these PDFs: pip3 install pypdf")
print()
return None
try:
reader = pypdf.PdfReader(filepath)
pages_text = []
for page in reader.pages:
txt = page.extract_text()
if txt:
pages_text.append(txt)
return ''.join(pages_text) if pages_text else None
except Exception:
return None
def read_file_with_textract(filepath, max_size):
"""Read text from a file, using textract for supported document formats.
For binary documents (docx, pdf, pptx, xlsx, epub, odt, rtf, etc.) uses textract.
Falls back to direct UTF-8 reading for all other files.
Returns the extracted text as a string, or None if extraction fails.
"""
ext = filepath.rsplit('.', 1)[-1].lower() if '.' in os.path.basename(filepath) else ''
# Try textract first for document formats (it handles all supported types)
if ext in TEXTRACT_SUPPORTED_EXTENSIONS:
try:
textract_mod = _try_import_textract()
if textract_mod:
extracted = textract_mod.process(filepath, encoding='utf-8')
if isinstance(extracted, bytes):
extracted = extracted.decode('utf-8', errors='ignore')
# For PDFs specifically, check if extraction produced meaningful text.
# pdfminer (used by textract) can fail on custom font encodings, producing
# garbled single characters per line. If so, fall through to pypdf fallback.
if ext == 'pdf' and extracted:
lines = [l.strip() for l in extracted.splitlines() if l.strip()]
# If most lines are 1-2 chars (garbled), try pypdf instead
short_lines = sum(1 for l in lines if len(l) <= 3)
if lines and short_lines > len(lines) * 0.5:
pass # fall through to pypdf below
else:
return extracted[:max_size]
elif ext != 'pdf':
return extracted[:max_size]
except Exception:
pass
# For PDFs, try pypdf as a fallback (handles custom font encodings better)
if ext == 'pdf':
pdf_text = _extract_pdf_text_pypdf(filepath)
if pdf_text:
return pdf_text[:max_size]
# Fallback: try direct UTF-8 reading for all files (plain text and unknown formats)
try:
with open(filepath, encoding='utf-8', errors='ignore') as f:
return f.read(max_size)
except Exception:
return None
def _text_size_limit(filepath):
"""Return the max file size to consider for text scanning, based on file type.
Textract-extractable documents (pdf, docx, xlsx, ...) may be much larger than their text
content, so they get MAX_DOCUMENT_FILE_SIZE; everything else uses MAX_MNEMONIC_FILE_SIZE.
"""
ext = filepath.rsplit('.', 1)[-1].lower() if '.' in os.path.basename(filepath) else ''
return MAX_DOCUMENT_FILE_SIZE if ext in TEXTRACT_SUPPORTED_EXTENSIONS else MAX_MNEMONIC_FILE_SIZE
def get_wallet_type_name(wallet_obj):
"""Extract wallet type name from a loaded wallet object."""
return type(wallet_obj).__name__
# ---------------------------------------------------------------------------
# Path truncation helpers
# ---------------------------------------------------------------------------
def _truncate_path_component(name, max_len=8, aggressive=False):
"""Truncate a single path component (directory or filename).
Normal mode: shows first 3 chars + '..' + last 3 chars when longer than max_len.
Aggressive mode: shows first 1 char + '.' + last 1 char for all components > 1 char.
Single-char names are returned unchanged in both modes.
"""
if len(name) <= 1:
return name
if aggressive:
return name[0] + '.' + name[-1]
if len(name) <= max_len:
return name
return name[:3] + '..' + name[-3:]
def _format_path_for_display(path_str, max_length=60):
"""Format a path for display.
Shows the full absolute path when it fits within max_length characters.
If longer than max_length, applies per-component truncation (first 3 + '..' + last 3).
If still longer than 60 chars after normal truncation, uses aggressive truncation
(first 1 + '.' + last 1) applied uniformly to all path components for consistency.
"""
# Resolve to absolute path
abs_path = str(Path(path_str).resolve())
if len(abs_path) <= max_length:
return abs_path
parts = Path(abs_path).parts
truncated = [_truncate_path_component(p, aggressive=False) for p in parts]
result = os.sep.join(truncated)
# If still too long (>60 chars), use aggressive truncation on all components uniformly
if len(result) > 60:
truncated = [_truncate_path_component(p, aggressive=True) for p in parts]
result = os.sep.join(truncated)
return result
class _TimedResult:
"""Container for timed operation results."""
def __init__(self):
self.value = None
self.exception = None
def _timed_operation(func, args=(), timeout=10):
"""Run a function with a timeout.
Returns the result on success, raises the original exception if one occurred,
or returns None if the operation timed out (or Ctrl+C was pressed). Suppresses
stdout/stderr produced by the operation.
The work runs in a daemon thread that we simply stop waiting on once the deadline
passes. We deliberately do NOT join/shutdown-wait on timeout: some OS calls (stat or
listdir on reparse points/junctions, dead network mounts, pagefile, System Volume
Information, ...) block uninterruptibly, and waiting for them to finish would defeat
the timeout and hang the whole scan. The daemon worker is abandoned (it cannot keep
the process alive) and reaped at interpreter exit.
Output is redirected on the *calling* thread rather than by having the worker swap the
global sys.stdout: if the worker hung mid-call, a worker-side swap would leak the
redirect and silence every subsequent print.
"""
import io
import time
from contextlib import redirect_stdout, redirect_stderr
tr = _TimedResult()
def target():
try:
tr.value = func(*args)
except BaseException as e:
tr.exception = e
worker = threading.Thread(target=target, daemon=True)
sink = io.StringIO()
with redirect_stdout(sink), redirect_stderr(sink):
worker.start()
deadline = time.monotonic() + timeout
while True:
if _should_stop.is_set():
return None
remaining = deadline - time.monotonic()
if remaining <= 0:
return None # timed out: abandon the daemon worker rather than wait on it
# Poll in short intervals so Ctrl+C stays responsive.
worker.join(timeout=min(remaining, 0.5))
if not worker.is_alive():
break
if tr.exception is not None:
raise tr.exception
return tr.value
# ---------------------------------------------------------------------------
# Progress indicator
# ---------------------------------------------------------------------------
# When stdout is redirected, plain milestone lines replace the in-place progress display;
# one line per this many items keeps a large scan's log output to a handful of lines.
_MILESTONE_INTERVAL = 10000
def _progress_enabled():
"""True when stdout is a real terminal. In-place '\\r' progress rewrites only make
sense on a TTY; when stdout is redirected to a file or pipe every rewrite would be
appended verbatim (hundreds of MB on a large scan), so they are suppressed and
replaced by occasional plain milestone lines."""
try:
return sys.stdout.isatty()
except Exception:
return False
def _print_scan_status(count, path, noun="items"):
"""Write the single-line 'Scanning: N ...' status used by the no-statusbar scans.
On a TTY this rewrites one line in place; when stdout is redirected it prints a plain
newline-terminated milestone every _MILESTONE_INTERVAL items instead.
"""
if not _progress_enabled():
# flush so the line appears immediately when the file is followed with tail -f
if count % _MILESTONE_INTERVAL == 0:
print("Scanning: {} {} checked...".format(count, noun), flush=True)
return
spinners = ['|', '/', '-', '\\']
display_path = _format_path_for_display(path)
line = "\r[{}] Scanning: {} {} Path: {}".format(
spinners[count % 4], count, noun, display_path)
max_len = 120
if len(line) < max_len:
line += ' ' * (max_len - len(line))
sys.stdout.write(line)
sys.stdout.flush()
def _print_progress(current, total=None, filepath=""):
"""Print a single-line progress indicator with a spinning cursor.
Updates in-place by using carriage return to overwrite the line.
Uses ASCII-safe characters for Windows console compatibility.
Displays full absolute path when <= 60 chars, otherwise truncates components.
When stdout is not a terminal, prints a plain milestone line every
_MILESTONE_INTERVAL candidates (and at completion) instead.
"""
if not _progress_enabled():
# flush so the line appears immediately when the file is followed with tail -f
if current % _MILESTONE_INTERVAL == 0 or (total and current == total):
if total:
print("Scanned {}/{} candidates...".format(current, total), flush=True)
else:
print("Scanned {} files...".format(current), flush=True)
return
spinners = ['|', '/', '-', '\\']
spinner_idx = current % 4
spinner = spinners[spinner_idx]
display_path = _format_path_for_display(filepath)
if total and total > 0:
pct = min(int(current / total * 100), 100)
bar_len = 20
filled = int(bar_len * current / total)
bar = '#' * filled + '-' * (bar_len - filled)
line = "\r[{}] Scanning: {}% [{}] {}/{} Dir: {}".format(
spinner, pct, bar, current, total, display_path)
else:
line = "\r[{}] Scanning: {} files checked Dir: {}".format(
spinner, current, display_path)
# Pad to clear previous line content
max_len = 120
if len(line) < max_len:
line += ' ' * (max_len - len(line))
sys.stdout.write(line)
sys.stdout.flush()
def _clear_progress_line():
"""Clear the progress indicator line (no-op when stdout is not a terminal)."""
if not _progress_enabled():
return
sys.stdout.write("\r" + " " * 120 + "\r")
sys.stdout.flush()
def _make_discovery_reporter(interval=0.2):
"""Return a progress_cb for _collect_wallet_candidates that shows the directory currently
being walked, throttled to at most one update per `interval` seconds.
Because it updates on a time interval (not per-candidate) and prints the current path, the
discovery phase stays visibly alive even through large directory trees that yield no
candidates — and it reveals exactly which path a slow or stuck walk is on.
"""
import time
spinners = ['|', '/', '-', '\\']
state = {'last': 0.0, 'count': 0}
def report(dir_path):
state['count'] += 1
if not _progress_enabled():
# Redirected output: a plain milestone line instead of the in-place display,
# flushed so it appears immediately when the file is followed with tail -f.
if state['count'] % _MILESTONE_INTERVAL == 0:
print("Discovering... {} dirs".format(state['count']), flush=True)
return
now = time.monotonic()
if now - state['last'] < interval:
return
state['last'] = now
spinner = spinners[state['count'] % 4]
display_path = _format_path_for_display(dir_path)
line = "\r[{}] Discovering... {} dirs Dir: {}".format(spinner, state['count'], display_path)
max_len = 120
if len(line) < max_len:
line += ' ' * (max_len - len(line))
sys.stdout.write(line[:max_len])
sys.stdout.flush()
return report
# ---------------------------------------------------------------------------
# Exclusion list helpers
# ---------------------------------------------------------------------------
def load_exclusions():
"""Load path exclusion patterns from the bundled walletfinder-exclusionlist.txt.
Returns a list of normalized (forward-slash, casefolded) patterns. Blank lines and lines
starting with '#' are ignored, as is an inline ' # comment' after an entry. Missing
file -> empty list. Each entry is matched against a scanned file's path *relative to the
scan root* (see _is_excluded): entries without wildcards are plain substrings, while
entries containing '*' or '?' are shell-style globs. Repo-relative entries like
'btcrecover/test/' therefore only skip the repository's own files when the repo is
scanned, and won't accidentally exclude a user's unrelated folders.
"""
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), EXCLUSIONLIST_FILENAME)
exclusions = []
try:
with open(path, encoding='utf-8') as f:
for line in f:
line = re.sub(r'\s+#.*$', '', line).strip() # strip inline comments
if not line or line.startswith('#'):
continue
exclusions.append(line.replace('\\', '/').casefold())
except OSError:
pass
return exclusions
def _relative_norm(path_str, root):
"""Return path_str relative to root, normalized to forward slashes."""
try:
rel = os.path.relpath(str(path_str), str(root))
except ValueError: # e.g. different drives on Windows
rel = str(path_str)
return rel.replace('\\', '/')
def _exclusion_matches(pattern, rel):
"""True if one exclusion pattern matches the casefolded, /-normalized relative path.
Patterns containing '*' or '?' are shell-style globs (fnmatch, where '*' also crosses
'/'), matched against the whole relative path either from its start or from any
directory boundary; a glob ending in '/' matches everything beneath that directory,
and a glob with no '/' is additionally matched against the basename alone. All other
patterns match as plain substrings anywhere in the relative path.
"""
if '*' in pattern or '?' in pattern:
# A directory glob like 'bitcoinlib*/examples/' must match the files beneath it,
# not just the directory itself, so match its whole subtree.
variants = (pattern, pattern + '*') if pattern.endswith('/') else (pattern,)
for pat in variants:
if fnmatch.fnmatchcase(rel, pat) or fnmatch.fnmatchcase(rel, '*/' + pat):
return True
if '/' not in pattern:
return fnmatch.fnmatchcase(rel.rsplit('/', 1)[-1], pattern)
return False
return pattern in rel
def _is_excluded(path_str, root, exclusions, is_dir=False):
"""True if path_str (relative to root) matches any exclusion pattern.
Matching is case-insensitive. Directories are matched with a trailing '/' appended so
directory patterns like 'site-packages/' prune the walk instead of testing every file
beneath them.
"""
if not exclusions:
return False
rel = _relative_norm(path_str, root).casefold()
if is_dir:
rel += '/'
return any(_exclusion_matches(ex, rel) for ex in exclusions)
def walk_directory(folder, max_depth, current_depth=0, exclusions=None, root=None):
"""Walk directory tree with depth limiting and exclusion filtering.
Yields each file path (as a string). Entries whose path relative to `root` matches an
entry in `exclusions` are skipped (directories are not descended into), which is how the
repository's own test wallets and example files are excluded from a `--folder .` scan.
"""
folder = Path(folder)
if root is None:
root = folder
if not folder.is_dir():
return
try:
entries = sorted(folder.iterdir())
except (PermissionError, FileNotFoundError, OSError):
return
for entry in entries:
try:
is_dir = entry.is_dir()
except OSError:
continue
if _is_excluded(entry, root, exclusions, is_dir=is_dir):
continue
if is_dir:
if entry.name.startswith('.') and entry.name != '.':
continue
if entry.name in EXCLUDED_DIRS:
continue
# OS pseudo/hardware filesystems (/proc, /sys, /dev, ...) hang or loop the walk.
if _is_system_dir(entry):
continue
# Don't follow symlinked directories: they can form loops (e.g. /proc/<pid>/root
# points back at /) and would otherwise recurse until the filesystem errors out.
if _is_symlink(entry):
continue
if max_depth is not None and current_depth >= max_depth:
continue
yield from walk_directory(entry, max_depth, current_depth + 1, exclusions, root)
elif entry.is_file():
yield str(entry)
# Optional btcrpass modules that certain wallet FILE types need in order to load, mapped to a
# human description and the pip command that provides them. Used by _announce_wallet_scan().
_WALLET_OPTIONAL_MODULES = [
('module_eth_keyfile_available', 'Ethereum / imToken keystore wallets', 'pip3 install eth-keyfile'),
('sjcl_available', 'BitGo wallets', 'pip3 install sjcl'),
('nacl_available', 'Toast wallets', 'pip3 install PyNaCl'),
('module_leveldb_available', 'MetaMask LevelDB vault folders',
'bundled leveldb support unavailable - reinstall BTCRecover'),
]
def _announce_wallet_scan():
"""Print the wallet file types that will be checked and warn about any missing optional
modules that would stop specific wallet types from loading."""
import btcrecover.btcrpass as btcrpass
names = [w.__name__ for w in getattr(btcrpass, 'wallet_types', [])]
print("Wallet file types checked ({}):".format(len(names)))
line = " "
for i, name in enumerate(names):
piece = name + (", " if i < len(names) - 1 else "")
if len(line) + len(piece) > 100:
print(line)
line = " "
line += piece
if line.strip():
print(line)
warnings = [(desc, hint) for flag, desc, hint in _WALLET_OPTIONAL_MODULES
if not getattr(btcrpass, flag, True)]
if warnings:
print()
for desc, hint in warnings:
print("[WARNING] Module missing: {} may not be detected/loaded ({}).".format(desc, hint))
print()
def _detect_wallet_file(filepath, debug=False):
"""Attempt to load a single file as a wallet using btcrecover's load_wallet().
Returns a result dict (path/type/confidence, plus reason when debug or unencrypted) if the
file is a recognised wallet, otherwise None. Runs with a 10-second timeout and swallows
the load errors raised for non-wallet files.
"""
try:
wallet_obj = _timed_operation(load_wallet, (filepath,), timeout=10)
if wallet_obj is not None:
result = {
'path': filepath,
'type': get_wallet_type_name(wallet_obj),
'confidence': getattr(wallet_obj, 'detection_confidence', 'definite'),
}
if debug:
result['reason'] = getattr(wallet_obj, 'detection_reason', None)
return result
except ValueError as e:
error_msg = str(e).lower()
if "not encrypted" in error_msg or "unencrypted" in error_msg:
return {
'path': filepath,
'type': 'Unencrypted',
'confidence': 'definite',
'reason': 'Wallet is not encrypted (contains exposed private keys)',
}
except (Exception, SystemExit):
pass
return None
def _looks_like_wallet_directory(dir_path):
"""Quick pre-filter: does this directory look like it could be a wallet?
MetaMask LevelDB vaults contain specific marker files (CURRENT, OPTIONS, LOCK, MANIFEST-*,
*.log). This check is very fast and avoids calling the expensive load_wallet on every
ordinary directory. Returns True if the directory contains indicators of being a wallet.
Wrapped with a short timeout so that problematic directories (e.g. Windows system folders)
do not block the scan indefinitely.
"""
try:
result = _timed_operation(_check_wallet_dir_contents, (dir_path,), timeout=5)
return result if result is not None else False
except Exception:
return False
def _check_wallet_dir_contents(dir_path):
"""Inner check for wallet directory markers (called inside a timeout wrapper)."""
try:
names = {e.name for e in dir_path.iterdir()}
except (PermissionError, FileNotFoundError, OSError):
return False
# LevelDB marker files indicate a MetaMask vault
leveldb_markers = {'CURRENT', 'OPTIONS', 'LOCK'}
has_log = any(n.endswith('.log') or n.startswith('MANIFEST-') for n in names)
return (names & leveldb_markers) or has_log
def _collect_wallet_candidates(folder, depth, exclusions=None, progress_cb=None):
"""Yield wallet scan candidates as they are discovered: both files and directories.
This is a generator so callers can display progress and start scanning as the walk
proceeds, instead of blocking until the entire tree has been enumerated (which, on a
drive root like ``C:\\``, would otherwise show nothing for a very long time).
Directories are tested because some wallets (e.g. MetaMask LevelDB vaults) are folders.
However, only directories that pass a quick pre-filter (_looks_like_wallet_directory) are
yielded as candidates to avoid wasting time on ordinary directories.
Files are filtered by MAX_WALLET_FILE_SIZE.
Yields (path_string, is_directory) tuples.
``progress_cb``, if given, is called with the string path of each directory as it is
entered — including directories that yield no candidates — so callers can show a live
indicator (and which path a slow/stuck walk is on) during long silent stretches.
"""
root = Path(folder)
max_depth = depth
def walk(dir_path, current_depth):
if _should_stop.is_set():
return
if progress_cb is not None:
progress_cb(str(dir_path))
if not dir_path.is_dir():
return
try:
entries = sorted(dir_path.iterdir())
except (PermissionError, FileNotFoundError, OSError):
return
for entry in entries:
if _should_stop.is_set():
return
try:
is_dir = entry.is_dir()
except OSError:
continue
if _is_excluded(entry, root, exclusions, is_dir=is_dir):
continue
if is_dir:
# OS pseudo/hardware filesystems (/proc, /sys, /dev, ...) hang or loop the
# walk; don't probe or descend into them at all.
if _is_system_dir(entry):
continue
# Only test directories that look like wallets (quick pre-filter)
if _looks_like_wallet_directory(entry):
yield (str(entry), True)
# Recurse into subdirectories unless depth limit reached. Skip symlinked
# directories: following them risks infinite loops (e.g. Linux
# /proc/<pid>/root points back at /) that blow up the walk.
if (not entry.name.startswith('.') and entry.name not in EXCLUDED_DIRS
and not _is_symlink(entry)):
if max_depth is None or current_depth < max_depth:
yield from walk(entry, current_depth + 1)
else:
# File candidate - check size
try:
fsize = os.path.getsize(str(entry))
if fsize <= MAX_WALLET_FILE_SIZE:
yield (str(entry), False)
except OSError:
pass
yield from walk(root, 0)
def scan_wallet_mode(folder, depth, debug=False, exclusions=None, statusbar=True):
"""Scan directory for wallet files using btcrecover's load_wallet().
Returns a list of dicts with keys: path, type, confidence, (and reason if debug).
Scans both files and directories as potential wallets. Directories are tested because
some wallets (e.g. MetaMask LevelDB vaults) are folders rather than single files.
When statusbar is True (default), uses a two-pass approach: first counts eligible candidates,
then scans them with a progress bar. When statusbar is False, scans immediately without
the initial discovery pass. Each candidate operation has a 10-second timeout.
"""
results = []
files_scanned = 0
_announce_wallet_scan()
if statusbar:
# First pass: collect all candidates (files + directories) for progress bar.
# The reporter shows the directory currently being walked so this phase stays
# visibly alive even on huge trees (e.g. a drive root) that take a while to enumerate.
total_candidates = 0
all_candidates = []
report = _make_discovery_reporter()
for candidate_path, is_dir in _collect_wallet_candidates(folder, depth, exclusions,
progress_cb=report):
if _should_stop.is_set():
break
all_candidates.append((candidate_path, is_dir))
total_candidates += 1
_clear_progress_line()
# Second pass: scan candidates with progress indicator (with timeout)
for i, (candidate_path, is_dir) in enumerate(all_candidates):
if _should_stop.is_set():
break
files_scanned += 1
_print_progress(files_scanned, total_candidates, candidate_path)
result = _detect_wallet_file(candidate_path, debug=debug)
if result is not None:
results.append(result)
# Clear the progress line and print a newline
_clear_progress_line()
else:
# No statusbar: scan candidates directly without counting first. The reporter keeps
# the display alive while the walk traverses directories that yield no candidates.
report = _make_discovery_reporter()
for candidate_path, is_dir in _collect_wallet_candidates(folder, depth, exclusions,
progress_cb=report):
if _should_stop.is_set():
break
files_scanned += 1
# Show current candidate being scanned (single-line updating display with absolute path)
_print_scan_status(files_scanned, candidate_path,
noun=("dirs" if is_dir else "files"))
result = _detect_wallet_file(candidate_path, debug=debug)
if result is not None:
results.append(result)
# Clear the scanning line
_clear_progress_line()
if _should_stop.is_set():
print("\nInterrupted by user.")
return results, files_scanned
# ---------------------------------------------------------------------------
# Private key detection patterns
# ---------------------------------------------------------------------------
# Base58 alphabet character class (excludes 0, O, I, l to avoid confusion)
B58 = r'[1-9A-HJ-NP-Za-km-z]'
# Raw WIF private keys:
# Uncompressed: 5 + 50 Base58 chars = 51 total
# Compressed K/L: K or L + 51 Base58 chars = 52 total
# Testnet compressed c: c + 51 Base58 chars = 52 total
RAW_WIF_PATTERN = re.compile(
r'(?<![A-Za-z0-9])'
r'(?:'
rf'5{B58}{{50}}' # uncompressed 5... (51 total)
rf'|K{B58}{{51}}' # compressed K... (52 total)
rf'|L{B58}{{51}}' # compressed L... (52 total)
rf'|c{B58}{{51}}' # testnet c... (52 total)
r')'
r'(?![A-Za-z0-9])',
re.ASCII
)
# BIP38 encrypted private keys: "6P" + 56 base58 chars = 58 total
BIP38_PATTERN = re.compile(
rf'(?<![A-Za-z0-9])6P{B58}{{56}}(?![A-Za-z0-9])',
re.ASCII
)
# BIP32 extended private keys: prefix (4 chars) + 107 base58 = 111 total
# SLIP-0132 registered prefixes: xprv, yprv, Yprv, zprv, Zprv, tprv, uprv, Uprv, vprv, Vprv
BIP32_XPRV_PATTERN = re.compile(
rf'(?<![A-Za-z0-9])(?:xprv|yprv|Yprv|zprv|Zprv|tprv|uprv|Uprv|vprv|Vprv){B58}{{107}}(?![A-Za-z0-9])',
re.ASCII
)
# BIP32 extended public keys: prefix (4 chars) + 106 or 107 base58 = ~110-111 total
# SLIP-0132 registered prefixes: xpub, ypub, Ypub, zpub, Zpub, tpub, upub, Upub, vpub, Vpub
BIP32_XPUB_PATTERN = re.compile(
rf'(?<![A-Za-z0-9])(?:xpub|ypub|Ypub|zpub|Zpub|tpub|upub|Upub|vpub|Vpub){B58}{{106,107}}(?![A-Za-z0-9])',
re.ASCII
)
def _classify_wif(key):
"""Return a human-readable label for a raw WIF key."""
if key.startswith('5'):
return 'Bitcoin (uncompressed)'
elif key[0] in ('K', 'L') and len(key) == 52:
return 'Bitcoin (compressed)'
elif key[0] == 'c':
return 'Testnet'
return 'Unknown network'
def _classify_xprv(key):
"""Return a human-readable label for an extended private key."""
prefix = key[:4]
labels = {
'xprv': 'Bitcoin mainnet (legacy)',
'yprv': 'Bitcoin mainnet (nested segwit)',
'Yprv': 'Bitcoin mainnet (multisig nested segwit)',
'zprv': 'Bitcoin mainnet (native segwit)',
'Zprv': 'Bitcoin mainnet (multisig native segwit)',
'tprv': 'Testnet (legacy)',
'uprv': 'Testnet (nested segwit)',
'Uprv': 'Testnet (multisig nested segwit)',
'vprv': 'Testnet (native segwit)',
'Vprv': 'Testnet (multisig native segwit)',
}
return labels.get(prefix, prefix)
def _classify_xpub(key):
"""Return a human-readable label for an extended public key."""
prefix = key[:4]
labels = {
'xpub': 'Bitcoin mainnet (legacy)',
'ypub': 'Bitcoin mainnet (nested segwit)',
'Ypub': 'Bitcoin mainnet (multisig nested segwit)',
'zpub': 'Bitcoin mainnet (native segwit)',
'Zpub': 'Bitcoin mainnet (multisig native segwit)',
'tpub': 'Testnet (legacy)',
'upub': 'Testnet (nested segwit)',
'Upub': 'Testnet (multisig nested segwit)',
'vpub': 'Testnet (native segwit)',
'Vpub': 'Testnet (multisig native segwit)',
}
return labels.get(prefix, prefix)
_B58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
_B58_INDEX = {c: i for i, c in enumerate(_B58_ALPHABET)}
def _b58check_valid(s):
"""Return True if s is a valid Base58Check string (trailing 4-byte double-SHA256 checksum).
WIF, BIP38, and BIP32 extended keys are all Base58Check-encoded, so this rejects random
base58-looking strings (e.g. despaced prose) that merely happen to match a key's length and
alphabet, without which key detection produces frequent false positives.
"""
num = 0
for ch in s:
v = _B58_INDEX.get(ch)
if v is None:
return False
num = num * 58 + v
decoded = num.to_bytes((num.bit_length() + 7) // 8, 'big') if num else b''
decoded = b'\x00' * (len(s) - len(s.lstrip('1'))) + decoded # restore leading zero bytes
if len(decoded) < 5:
return False
data, checksum = decoded[:-4], decoded[-4:]
return hashlib.sha256(hashlib.sha256(data).digest()).digest()[:4] == checksum
def scan_private_keys(content):
"""Scan extracted text content for private keys.
Returns a dict with keys: raw_wif, bip38, xprv, xpub.
Each value is a list of dicts with 'key' and 'network' fields.
Candidates that match a key pattern but fail Base58Check validation are discarded.
"""
findings = {
'raw_wif': [],
'bip38': [],
'xprv': [],
'xpub': [],
}
for match in RAW_WIF_PATTERN.finditer(content):
key = match.group(0)
if _b58check_valid(key):
findings['raw_wif'].append({
'key': key,
'network': _classify_wif(key),
})
for match in BIP38_PATTERN.finditer(content):
key = match.group(0)
if _b58check_valid(key):
findings['bip38'].append({
'key': key,
'network': 'BIP38 encrypted',
})
for match in BIP32_XPRV_PATTERN.finditer(content):
key = match.group(0)
if _b58check_valid(key):
findings['xprv'].append({
'key': key,
'network': _classify_xprv(key),
})
for match in BIP32_XPUB_PATTERN.finditer(content):
key = match.group(0)
if _b58check_valid(key):
findings['xpub'].append({
'key': key,
'network': _classify_xpub(key),
})
return findings
def scan_private_keys_all(content):
"""Scan for private keys, catching keys broken by whitespace (spaces, tabs, or newlines).
Runs scan_private_keys() on the content as-is and again on transformed copies:
1. Intra-line whitespace only removed (spaces/tabs) - preserves line boundaries
2. Newlines replaced with spaces + adjacent base58 tokens joined - recovers keys split
across PDF lines while Base58Check validation rejects false positives
Results are unioned (deduplicated per category by key string). The Base58Check validation
rejects random base58-looking strings, so these transformations are safe for key detection.
"""
findings = scan_private_keys(content)
def _merge_extra(extra):