fiddlesticks 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ Metadata-Version: 2.5
2
+ Name: fiddlesticks
3
+ Version: 0.4.0
4
+ Summary: Password recovery tool for encrypted files (.7z archives .kdbx files, and Aegis archives).
5
+ Project-URL: GitHub, https://github.com/Hazardous-Area/fiddlesticks
6
+ Author-email: James Parrott <james@jamesparrott.dev>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.12
10
+ Provides-Extra: aegis
11
+ Requires-Dist: py-avdu; extra == 'aegis'
12
+ Provides-Extra: keepassxc
13
+ Requires-Dist: pykeepass; extra == 'keepassxc'
14
+ Provides-Extra: msoffice
15
+ Requires-Dist: msoffcrypto-tool; extra == 'msoffice'
16
+ Provides-Extra: py7zr
17
+ Requires-Dist: py7zr; extra == 'py7zr'
18
+ Provides-Extra: ssh
19
+ Requires-Dist: bcrypt; extra == 'ssh'
20
+ Requires-Dist: cryptography>=47; extra == 'ssh'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # Fiddlesticks!
24
+ *"Aaaagh! I forgot my 7zip password"* - James (more times than he cares to remember).
25
+ ![Tests passing](https://github.com/Hazardous-Area/fiddlesticks/actions/workflows/tests.yml/badge.svg)
26
+ ![Code qual](https://github.com/Hazardous-Area/fiddlesticks/actions/workflows/lint.yml/badge.svg)
27
+
28
+ Version 0.4.0
29
+
30
+ ## Description
31
+ Password recovery tool, for password-encrypted files, using simple off-line brute
32
+ force attacks. Password candidates are generated, using common variations
33
+ of a guessed password (e.g. typos and substitutions). SSH keys, Aegis archives,
34
+ and Veracrypt volumes, plus .7z, .kdbx, .xlsx, and .docx files,
35
+ are directly supported as optional dependencies. But
36
+ Fiddlesticks can also call any shell command, that accepts a candidate password,
37
+ e.g. for Veracrypt volumes (and can pipe candidates to stdout).
38
+
39
+ ### Raison d'etre
40
+ - Password-protected file owners recovering their own password themselves, as long as
41
+ they can still recall a rough guess for their password, might only need to test
42
+ every candidate password that's similar enough to the guess.
43
+ - This may be a much faster and cheaper computation
44
+ than the one an adversary must do, without such a guess, but in possession
45
+ of a stolen password protected file[^0].
46
+
47
+ ### Warning
48
+ Strictly speaking, Fiddlesticks is a password-protected file recovery tool. Use it to
49
+ get your files back. But once you've found a password that protected those files via Fiddlesticks
50
+ (or any third party tool) it should not be used again. Anywhere else it is also used, the
51
+ password should be reset (or the files re-encrypted with a different one). By default,
52
+ Fiddlesticks does not print the password it finds (or any candidates) unless `-P` is set
53
+ (or if using `--pipe` with no pipe).
54
+
55
+ ### "Back of envelope" sketch 'calculation'
56
+ - Attackers targetting a truly[^0] random password, must try up to `2**N`
57
+ candidate passwords (for each bit length `N` being considered).
58
+ - Specifically, password owners may only need to consider every candidate within some
59
+ maximum [Weighted-Levenshtein distance](https://en.wikipedia.org/wiki/Edit_distance#Types_of_edit_distance)
60
+ from their best guess of the forgotten password, lets say a total of `M`.
61
+ - Fiddlesticks is intended to assist recovering passwords from "close enough" guesses,
62
+ when `M` is much smaller than `2**N`.
63
+ - If Fiddlesticks can crack an archive's password with a starting guess of an empty string,
64
+ (if `2**N` is also small enough to be feasible, with no guess) then anyone with the
65
+ archive can also do so - the password wasn't strong enough.
66
+ - If Fiddlesticks fails to crack an archive's password given some starting guess, a lower
67
+ bound on how similar the actual password is to the starting guess can still be deduced
68
+ (e.g. this could indicate that the starting guess was wrong).
69
+
70
+ ### Design and security notes
71
+ *"FAQ: Why the heck should anyone in their right mind trust this with their password?"*
72
+ - Any similar 3rd party password cracking service based on 'best guess' passwords, requires
73
+ the user to share the guesses for their passwords with the service. Even if the password
74
+ was not used for anything else, sharing even guesses for secret credentials with 3rd parties,
75
+ is a critical security issue.
76
+ - Fiddlesticks is designed to minimise the need for this. It is designed to i) be as easy
77
+ to install as possible, and in particular ii) require as few dependencies as possible.
78
+ Firstly, the intention of i) is to assist users to run Fiddlesticks in their own secure
79
+ environment, locked down as much as they want (e.g. offline and disconnected from
80
+ all external network access), without requiring them to take their
81
+ password guesses outside of that. For example, for recovery of password encrypted .7z archives,
82
+ only a normal installation of 7zip is required on Linux (plus a close enough guess of the password!).
83
+ Fiddlesticks can even generate a file of candidate passwords, for external programs, and other
84
+ possible applications. Secondly, ii) helps users decide for themselves whether or not to trust Fiddlesticks
85
+ in the first place. In particular whether or not it will take their password guesses outside
86
+ of its running environment. When the project was concieved, the intention was also to
87
+ iii) require as little code as possible. But the code base has since become somewhat more
88
+ complex, mainly to have a nice CLI. Simplicity and brevity should both be much
89
+ more highly prized features of software in general. But you
90
+ be the judge of whether or not c) is still the case. The application code is all in a single file.
91
+
92
+ #### General security audit
93
+ If considering using any password recovery program at all, perhaps consider the following:
94
+ - does it contain code contained in string literals or obfuscated code?
95
+ - does it utilise arbitrary code execution functions (e.g. `eval`, `exec` and `compile`)?
96
+ - is all the source code visible?
97
+ - does it contain thousands of lines of code?
98
+ - does it require hundreds of third-party dependencies?
99
+ - does it contain compiled binary executables, that are not easily readable?
100
+ - is the code statically typed (so run-time features of dynamic languages are minimised)?
101
+ - is all the code tested?
102
+ - is it tested with high coverage (so all the code really is run, somewhere in the tests)?
103
+ - does it import or install any libraries that allow communication across a network, that
104
+ could be used for exfiltration purposes?
105
+ - does it require root privileges to run?
106
+ - can it be run on any operating system, and in any Docker container?
107
+
108
+ Full disclaimer: Fiddlesticks does actually contain 8 lines of Bash in a string
109
+ literal (to avoid the overhead of `subprocess.run` for every single candidate to be
110
+ tested, and to demonstrate how any command line program could read
111
+ password candidates from stdin). Otherwise we hope the answers to all the other questions
112
+ with regards to Fiddlesticks, are all reassuring.
113
+
114
+ ### Usage
115
+ ```
116
+ uvx fiddlesticks
117
+ ```
118
+
119
+ For 7z archives, it is possible to simply to automate attempts to open the file via a stock
120
+ 7zip installed in the system, e.g. from:
121
+ ```
122
+ sudo apt update && sudo apt-get install 7zip
123
+ ```
124
+
125
+ Fiddlesticks can also decrypt KeepassXC vaults and Aegis vault files (for TOTP authenticators) if
126
+ [pykeepass](https://pypi.org/project/pykeepass/) and [py-avdu](https://pypi.org/project/py-avdu/) respectively, are
127
+ also installed.
128
+
129
+ There are a couple of alternative modes too,
130
+
131
+ - firstly: automating any partial external Bash command that a candidate password can be appended to (that exits with return code 0
132
+ for the correct password, otherwise some non-zero exit code). Specify `--shell` or by default if the partial command is specified after `--`.
133
+
134
+ Secondly with `--pipe` candidate passwords can be sent to stdout, from where they can be piped to stdin of a user's own external program or code (all the normal output from fiddlesticks goes to stderr).
135
+
136
+ Thirdly, if py7zr is also installed, with `--py7zr` fiddlesticks can use it to test passwords for 7z archives,
137
+ entirely within Python.
138
+
139
+ ### Main options
140
+ #### Password guesses
141
+ If no password is specified, and if a password guess is requires for the selected command,
142
+ the user will be prompted to enter their password guesses securely (via Python's `getpass.getpass`).
143
+ For convenience passwords may also be supplied on separate lines of a text file specified
144
+ in `--input-file`, or directly on the command line, each after `--password-guess` or `-p`. If using
145
+ the latter in Bash and similar shells, please be aware the full command entered may be saved
146
+ in the Bash history. After using Fiddlesticks you should run `history -a` and
147
+ asome `history -d $(history 1 | awk '{print $1}')` command to clear this.
148
+
149
+ #### Number of substitutions
150
+ For candidate password generation strategies that use character substitutions (the only ones supported so far)
151
+ the number of substitutions required for each candidate can be capped by setting `--max-subs`
152
+
153
+ #### Output control & verbosity
154
+ If a file is specified in `--output-file` or `-o` Fiddlesticks will write a successfully found
155
+ password to it. Unless `-P` or `--print-passwords` is set, Fiddlesticks does not print any
156
+ candidate passwords by default (on successfully finding a password, the
157
+ candidate number is printed; candidate generation is deterministic).
158
+ The number of output messages (printed to stderr) can be increased by raising
159
+ the verbosity, by setting `-v` or `--verbosity`, once or twice (e.g. `-vv`).
160
+ "Two" is the maximum verbosity available.
161
+
162
+ ### Custom alternative character maps
163
+
164
+ Any custom JSON file supplied to `--char-map` containing a mapping of characters to alternatives can be used (instead of the default L33t substitutions and capitalisations ascii-based map). To assist creating this, the default map (or the relevant
165
+ sub-mapping applicable for a specified password guess) can be printed to stderr with `--print-char-map` (and `>>`ed
166
+ to a local file, where its JSON can be customised in any text editor). This provides a simple method for
167
+ limiting the substitutions to specific characters in a password guess.
168
+
169
+ ### Other Notes
170
+ - Successful attempts to extract a password-protected archive, result in the archive being
171
+ unencrypted (naturally) by some methods. Currently all such plaintext unencrypted archives
172
+ are not deleted from the file system afterwards. If the options `--extract-to` or `-x`
173
+ are given, archives are extracted there.
174
+ - Fiddlesticks cannot recover passwords for online accounts. Online password entry attempts
175
+ should be rate limited. Cracking is only possible locally if the website owner shares the
176
+ password hash with the user, in which case they can probably provide the rest of their
177
+ account data too.
178
+ - If Fiddlesticks fails to 'crack' or find a known password, this should not be taken as
179
+ proof of the password's strength. It won't ever be possible to think everything, and
180
+ we certainly don't wish users to draw a false sense of security from Fiddlesticks.
181
+
182
+ ## Alternatives
183
+ - https://github.com/philsmd/7z2hashcat
184
+ - https://en.wikipedia.org/wiki/Dictionary_attack#Dictionary_attack_software
185
+
186
+ [^0] Truly random passwords are difficult for humans to remember (without writing them down or saving them).
187
+ At the very least, real world adversaries (posessing a stolen file or password hash) are likely to first attempt a [dictionary attack](https://en.wikipedia.org/wiki/Dictionary_attack#Dictionary_attack_software)
@@ -0,0 +1,6 @@
1
+ fiddlesticks.py,sha256=Vn9vMAY8SFwsaQv4fxc6lCTctr4l5CwUGEK3mAQGKJM,25147
2
+ fiddlesticks-0.4.0.dist-info/METADATA,sha256=7p40Gsyh8HqnRB1PAIZ4-DG3BjqfqqS_pTVpT3FJGrc,11239
3
+ fiddlesticks-0.4.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
4
+ fiddlesticks-0.4.0.dist-info/entry_points.txt,sha256=ooNwBgge2DVOiW8b8_l6DTJsecsOhHo6abQR6_hE12I,50
5
+ fiddlesticks-0.4.0.dist-info/licenses/LICENSE,sha256=Kb1fzpFcCzGbxK1-XIK9I9dH5SGGQWZHHe_wocdEOzE,1076
6
+ fiddlesticks-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fiddlesticks = fiddlesticks:cli
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 James Parrott
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the "Software"), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7
+ of the Software, and to permit persons to whom the Software is furnished to do
8
+ so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
fiddlesticks.py ADDED
@@ -0,0 +1,894 @@
1
+ # /// script
2
+ # requires-python = ">=3.12"
3
+ # dependencies = []
4
+ # ///
5
+
6
+ # # Non-compliant with PEP 723:
7
+ # optional_dependencies = [
8
+ # # External 7zip, e.g.:
9
+ # # sudo apt-get install -y 7zip
10
+ # ssh = ["bcrypt", "cryptography>=47"], # v47 min for "incorrect PW" error message from Rust code
11
+ # aegis = ["py-avdu"],
12
+ # keepassxc = ["pykeepass"],
13
+ # # External Veracrypt, e.g.:
14
+ # # sudo add-apt-repository ppa:unit193/encryption
15
+ # # sudo apt update
16
+ # # sudo apt install veracrypt
17
+ # msoffice = ["msoffcrypto-tool"]
18
+ # py7zr = ["py7zr"],
19
+ # ]
20
+ # ///
21
+
22
+ __version__ = "0.4.0"
23
+
24
+ import argparse
25
+ import atexit
26
+ import getpass
27
+ import io
28
+ import itertools
29
+ import json
30
+ import os
31
+ import string
32
+ import subprocess
33
+ import sys
34
+ import tempfile
35
+ import textwrap
36
+ import time
37
+ import warnings
38
+ from collections import defaultdict
39
+ from collections.abc import Callable, Iterable, Iterator
40
+ from pathlib import Path
41
+ from typing import cast
42
+
43
+ TMP_DIR = Path(tempfile.gettempdir()) / "fiddlesticks"
44
+ TMP_DIR.mkdir(exist_ok=True)
45
+ IS_WINDOWS = sys.platform == "win32"
46
+
47
+
48
+ SHIFT_MAP: dict[str, str] = {
49
+ "1": "!",
50
+ "2": '@"',
51
+ "3": "#£",
52
+ "4": "$",
53
+ "5": "%",
54
+ "6": "^",
55
+ "7": "&",
56
+ "8": "*",
57
+ "9": "(",
58
+ "0": ")",
59
+ "-": "_",
60
+ "=": "+",
61
+ "[": "{",
62
+ "]": "}",
63
+ "\\": "|",
64
+ ";": ":",
65
+ "'": "@",
66
+ ",": "<",
67
+ ".": ">",
68
+ "/": "?",
69
+ "#": "~",
70
+ "`": "~",
71
+ }
72
+
73
+ for c in string.ascii_lowercase:
74
+ SHIFT_MAP[c] = c.upper()
75
+
76
+ LEET_SPEAK: dict[str, str] = {
77
+ "a": "4@",
78
+ "e": "3",
79
+ "i": "1",
80
+ "o": "0",
81
+ "s": "5",
82
+ "t": "7",
83
+ "b": "8",
84
+ "g": "9",
85
+ }
86
+
87
+ for c, v in list(LEET_SPEAK.items()):
88
+ LEET_SPEAK[c.upper()] = v
89
+
90
+
91
+ def print_to_stderr(*objects, **kwargs):
92
+ """Simple wrapper to print to stderr instead of stdout,
93
+ for easy piping (of other output) to stdout.
94
+ """
95
+ print(*objects, file=sys.stderr, **kwargs)
96
+
97
+
98
+ def _calculate_total(lengths, M):
99
+ # dp[j] = sum of products for choosing j items
100
+ dp = [0] * (M + 1)
101
+ dp[0] = 1
102
+
103
+ for length in lengths:
104
+ for j in range(M, 0, -1):
105
+ dp[j] += dp[j - 1] * length
106
+
107
+ return dp[M]
108
+
109
+
110
+ def _combine_maps(
111
+ subs_maps: Iterable[dict[str, str]],
112
+ ) -> defaultdict[str, list[str]]:
113
+
114
+ bi_map: defaultdict[str, list[str]] = defaultdict(list)
115
+ for subs_map in subs_maps:
116
+ for k, v in subs_map.items():
117
+ bi_map[k].extend(v)
118
+ for c in v:
119
+ bi_map[c].append(k)
120
+ return bi_map
121
+
122
+
123
+ SHIFT_AND_LEET_BI_MAP = _combine_maps([SHIFT_MAP, LEET_SPEAK])
124
+
125
+
126
+ def _candidates_from_num_subs(
127
+ guess: str,
128
+ num_subs: int,
129
+ alts: list[list[str]],
130
+ ) -> Iterator[tuple[str, int]]:
131
+ if num_subs == 0:
132
+ yield guess, 0
133
+ return
134
+ for positions in itertools.combinations(range(len(guess)), num_subs):
135
+ alts_at_positions = [alts[i] for i in positions if alts[i]]
136
+
137
+ if len(alts_at_positions) != num_subs:
138
+ continue
139
+
140
+ for selected in itertools.product(*alts_at_positions):
141
+ candidate_password = list(guess)
142
+ for i, replacement in zip(positions, selected):
143
+ candidate_password[i] = replacement
144
+ yield "".join(candidate_password), num_subs
145
+
146
+
147
+ def _candidates_from_alts_dict(
148
+ guesses_alts: dict[str, list[list[str]]],
149
+ max_subs: int,
150
+ ) -> Iterator[tuple[str, int]]:
151
+ for num_subs in range(max_subs + 1):
152
+ # Yield candidates derived from each guess using
153
+ # a not quite Round robin order (that restarts
154
+ # from the earliest iterator after one is exhausted).
155
+ iterators = [
156
+ _candidates_from_num_subs(guess, num_subs, alts)
157
+ for guess, alts in guesses_alts.items()
158
+ ]
159
+ while iterators:
160
+ # Coverage would like to see tests covering iterators being empty,
161
+ # which is not reachable within a while iterators: loop.
162
+ for i, iterator in itertools.cycle(
163
+ enumerate(iterators)
164
+ ): # pragma: no branch
165
+ # More itertools' roundrobin just breaks out of the loop
166
+ # using a next call with no fallback value, to raise StopIteration
167
+ candidate = next(iterator, None)
168
+ if candidate is None:
169
+ break
170
+ yield candidate
171
+ # Get rid of exhausted iterator
172
+ iterators.pop(i)
173
+
174
+
175
+ def candidate_passwords_from_alt_chars(
176
+ guesses: list[str],
177
+ max_subs: int = 2,
178
+ alt_chars: list[list[list[str]]] | None = None,
179
+ alt_char_map: defaultdict[str, list[str]] = SHIFT_AND_LEET_BI_MAP,
180
+ ) -> tuple[int, Iterator[tuple[str, int]]]:
181
+
182
+ overrides = [None for guess in guesses] if alt_chars is None else alt_chars
183
+ guesses_alts: dict[str, list[list[str]]]
184
+ guesses_alts = {
185
+ # In case alt_char_map[c] is a str
186
+ guess: [list(alt_char_map[c]) for c in guess] if alts is None else alts
187
+ for guess, alts in zip(guesses, overrides)
188
+ }
189
+
190
+ total_num_candidates = 0
191
+ for alts in guesses_alts.values():
192
+ lengths = [len(chars) for chars in alts]
193
+ total_num_candidates += sum(
194
+ _calculate_total(lengths, M) for M in range(max_subs + 1)
195
+ )
196
+
197
+ return total_num_candidates, _candidates_from_alts_dict(guesses_alts, max_subs)
198
+
199
+
200
+ def possibly_output_found_password(
201
+ password: str,
202
+ i: int,
203
+ t: float | None = None,
204
+ print_passwords: bool = False,
205
+ output_file: str = "",
206
+ **kwargs,
207
+ ):
208
+ msg = f"\n Found password (candidate number: {i})"
209
+ if t is not None:
210
+ msg = f"{msg} in {t:.3f} seconds"
211
+ print_to_stderr(msg, end="")
212
+
213
+ print_to_stderr(f" {password=}" if print_passwords else "")
214
+
215
+ if output_file:
216
+ with open(output_file, "at") as f:
217
+ f.write(password)
218
+
219
+
220
+ def make_py7zr_checker(archive: str, extract_to: str | None = None, **kwargs):
221
+ from _lzma import LZMAError
222
+
223
+ import py7zr
224
+ from py7zr.exceptions import Bad7zFile, PasswordRequired
225
+
226
+ if extract_to is None:
227
+ extract_to = str(_make_new_tmp_sub_dir_for_7z(archive))
228
+ stream = io.BytesIO(Path(archive).read_bytes())
229
+
230
+ def is_correct_password_for_7z_file(candidate: str) -> bool:
231
+ stream.seek(0)
232
+ try:
233
+ f = py7zr.SevenZipFile(stream, "r", password=candidate)
234
+ f.extractall(path=extract_to)
235
+ except (PasswordRequired, Bad7zFile, LZMAError):
236
+ return False
237
+ f.close()
238
+ return True
239
+
240
+ return is_correct_password_for_7z_file
241
+
242
+
243
+ def make_subprocess_checker(*args: str, **kwargs):
244
+
245
+ # If args[-1][-1] = " ", it will get escaped
246
+ # and quoted together with the appended password.
247
+ # Interpreting that space as a Bash word separator
248
+ # requires shell=True.
249
+ # On the other hand, if last == "-p", e.g. with 7z,
250
+ # the password is expected with no space separating it
251
+ # from the -p.
252
+ # Therefore to honour our contract of "any partial
253
+ # Bash command to which a password guess can be appended"
254
+ # it's easiest to use a single string (instead of an args list),
255
+ # and (unless on Windows) shell=True.
256
+
257
+ def checker(candidate: str) -> bool:
258
+ result = subprocess.run(
259
+ f"{' '.join(args)}{candidate}",
260
+ capture_output=True,
261
+ check=False,
262
+ shell=not IS_WINDOWS,
263
+ )
264
+ return result.returncode == 0
265
+
266
+ return checker
267
+
268
+
269
+ def _make_new_tmp_sub_dir(tmp_dir, name: str = "extracted") -> Path:
270
+ i = -1
271
+ suffix = ""
272
+ while (p := tmp_dir / f"{name}{suffix}").is_dir():
273
+ i += 1
274
+ suffix = f"_{i}"
275
+ p.mkdir(parents=True, exist_ok=False)
276
+ return p
277
+
278
+
279
+ def _make_new_tmp_sub_dir_for_7z(file: str, tmp_dir: Path = TMP_DIR) -> Path:
280
+ p = _make_new_tmp_sub_dir(tmp_dir)
281
+ print_to_stderr(f"If {file} is unzipped successfully, contents will be in: {p}")
282
+ return p
283
+
284
+
285
+ def make_7zip_checker(file: str, extract_to: str | None = None, **kwargs):
286
+
287
+ # Ensure we can run 7zip in a subprocess.
288
+ subprocess.run(["7z", "--help"], capture_output=True, check=True)
289
+
290
+ if extract_to is None:
291
+ extract_to = str(_make_new_tmp_sub_dir_for_7z(file))
292
+
293
+ return make_subprocess_checker("7z", "x", f"-o{extract_to}", file, "-p")
294
+
295
+
296
+ def make_password_candidate_piper(*args, **kwargs):
297
+ def piper(password: str):
298
+ print(password, file=sys.stdout)
299
+ return False
300
+
301
+ return piper
302
+
303
+
304
+ PERSISTENT_7Z_CHECKER_OUTLINE = """\
305
+ #!/usr/bin/env bash
306
+
307
+ while read -r line; do
308
+ # Silently run command, only check exit code
309
+ 7z x -o{extract_to} {file} -p"$line" > /dev/null 2>&1
310
+ if [ $? -eq 0 ]; then
311
+ echo "Success! :)"
312
+ break
313
+ fi
314
+ echo "Nope :("
315
+ done
316
+ """
317
+
318
+
319
+ def make_persistent_7zip_checker(file: str, extract_to: str | None = None, **kwargs):
320
+ if extract_to is None:
321
+ extract_to = str(_make_new_tmp_sub_dir_for_7z(file))
322
+
323
+ cmd = textwrap.dedent(
324
+ PERSISTENT_7Z_CHECKER_OUTLINE.format(extract_to=extract_to, file=file)
325
+ )
326
+
327
+ # Launch a single persistent Bash process reading from stdin line-by-line
328
+ proc = subprocess.Popen(
329
+ ["bash", "-c", cmd],
330
+ stdin=subprocess.PIPE,
331
+ stdout=subprocess.PIPE,
332
+ text=True,
333
+ bufsize=1, # Line buffered
334
+ )
335
+
336
+ stdin = cast(io.TextIOBase, proc.stdin)
337
+ stdout = cast(io.TextIOBase, proc.stdout)
338
+
339
+ def checker(candidate: str) -> bool:
340
+ # Send data down the pipe
341
+ stdin.write(f"{candidate}\n")
342
+ stdin.flush()
343
+
344
+ # Read the response back
345
+ response = stdout.readline().strip()
346
+ return "Success" in response
347
+
348
+ @atexit.register
349
+ def cleanup():
350
+ stdin.close()
351
+ proc.wait()
352
+
353
+ return checker
354
+
355
+
356
+ def make_py_avdu_aegis_checker(file: str, **kwargs):
357
+
358
+ from py_avdu.encrypted_classes import VaultEncrypted
359
+
360
+ vault_dict = json.loads(Path(file).read_text())
361
+
362
+ encrypted = VaultEncrypted(**vault_dict)
363
+
364
+ def checker(candidate: str) -> bool:
365
+ try:
366
+ encrypted.find_master_key(candidate)
367
+ return True
368
+ except ValueError:
369
+ return False
370
+
371
+ return checker
372
+
373
+
374
+ def make_pykeepass_checker(file: os.PathLike, **kwargs):
375
+
376
+ from pykeepass import PyKeePass
377
+ from pykeepass.exceptions import CredentialsError
378
+
379
+ def checker(candidate: str) -> bool:
380
+ try:
381
+ PyKeePass(file, password=candidate)
382
+ return True
383
+ except CredentialsError:
384
+ return False
385
+
386
+ return checker
387
+
388
+
389
+ def _get_hopefully_incorrect_password() -> str:
390
+ try:
391
+ return getpass.getuser()
392
+ except OSError:
393
+ return "password123"
394
+
395
+
396
+ def _try_make_ssh_key_checker_from_loader(
397
+ loader,
398
+ incorrect_password_msg: str,
399
+ file: os.PathLike,
400
+ **kwargs,
401
+ ) -> Callable[[str], bool]:
402
+
403
+ private_key_data = Path(file).read_bytes()
404
+ hopefully_incorrect_password = _get_hopefully_incorrect_password()
405
+ try:
406
+ loader(private_key_data, password=hopefully_incorrect_password.encode())
407
+ except ValueError as e:
408
+ if e.args[0] != incorrect_password_msg:
409
+ raise
410
+ else:
411
+ possibly_output_found_password(
412
+ hopefully_incorrect_password,
413
+ i=-12345,
414
+ t=None,
415
+ **kwargs,
416
+ )
417
+ sys.exit(0)
418
+
419
+ def checker(candidate: str) -> bool:
420
+ try:
421
+ loader(
422
+ private_key_data,
423
+ password=candidate.encode(),
424
+ # Fail fast. Key pair validation is out of scope
425
+ # (but on success we do it anyway to give the user a heads up).
426
+ # https://cryptography.io/en/latest/hazmat/primitives/asymmetric/serialization/#cryptography.hazmat.primitives.serialization.load_ssh_private_key
427
+ unsafe_skip_rsa_key_validation=True,
428
+ )
429
+ except ValueError:
430
+ return False
431
+ loader(
432
+ private_key_data,
433
+ password=candidate.encode(),
434
+ # Validate key pair, only once we already know
435
+ # we have the correct password.
436
+ unsafe_skip_rsa_key_validation=False,
437
+ )
438
+ return True
439
+
440
+ return checker
441
+
442
+
443
+ def make_ssh_key_checker(file: os.PathLike, **kwargs):
444
+
445
+ exceptions = []
446
+
447
+ for factory in [
448
+ make_openSSH_key_checker,
449
+ make_ssh_pem_key_checker,
450
+ ]:
451
+ try:
452
+ return factory(file, **kwargs)
453
+ except ValueError as e:
454
+ exceptions.append(e)
455
+
456
+ raise ExceptionGroup(
457
+ (
458
+ f"Could not find valid SSH key loader for {file=}. "
459
+ "Is it corrupted or in the incorrect format?"
460
+ ),
461
+ exceptions,
462
+ )
463
+
464
+
465
+ def make_openSSH_key_checker(file: os.PathLike, **kwargs):
466
+ from cryptography.hazmat.primitives.serialization import load_ssh_private_key
467
+
468
+ return _try_make_ssh_key_checker_from_loader(
469
+ load_ssh_private_key,
470
+ "Corrupt data: broken checksum", # Defined in cryptography's ssh.py, since 2020
471
+ file,
472
+ **kwargs,
473
+ )
474
+
475
+
476
+ def make_ssh_pem_key_checker(file: os.PathLike, **kwargs):
477
+ from cryptography.hazmat.primitives.serialization import load_pem_private_key
478
+
479
+ return _try_make_ssh_key_checker_from_loader(
480
+ load_pem_private_key,
481
+ "Incorrect password, could not decrypt key", # Defined in cryptography's Rust extension since Apr 2025
482
+ # TODO: Investigate the other error strings that have been seen. See misc_tests.py
483
+ file,
484
+ **kwargs,
485
+ )
486
+
487
+
488
+ def make_MS_Office_files_key_checker(file: os.PathLike, **kwargs):
489
+
490
+ import msoffcrypto
491
+ import msoffcrypto.exceptions
492
+
493
+ encrypted = io.BytesIO(Path(file).read_bytes())
494
+ office_file = msoffcrypto.OfficeFile(encrypted)
495
+
496
+ stream = io.BytesIO()
497
+
498
+ def checker(candidate: str) -> bool:
499
+ office_file.load_key(password=candidate)
500
+ try:
501
+ office_file.decrypt(stream)
502
+ return True
503
+ except msoffcrypto.exceptions.InvalidKeyError:
504
+ return False
505
+
506
+ return checker
507
+
508
+
509
+ def make_Veracrypt_checker(file: os.PathLike, **kwargs):
510
+
511
+ path = Path(file).resolve()
512
+ assert path.is_file()
513
+
514
+ # Ensure we can run Veracrypt in a subprocess.
515
+ subprocess.run(["veracrypt", "--help"], capture_output=True, check=True)
516
+
517
+ temp_dir = tempfile.TemporaryDirectory(delete=False)
518
+
519
+ mount_point = _make_new_tmp_sub_dir(
520
+ tmp_dir=Path(temp_dir.name) / "mnt",
521
+ name="veracrypt_volume",
522
+ ).resolve()
523
+
524
+ args = [
525
+ "veracrypt",
526
+ "--text",
527
+ "--non-interactive",
528
+ "--keyfiles=",
529
+ "--pim=0",
530
+ "--protect-hidden=no",
531
+ "--mount",
532
+ path.as_posix(),
533
+ mount_point.as_posix(),
534
+ "--password=",
535
+ ]
536
+
537
+ @atexit.register
538
+ def cleanup():
539
+ subprocess.run(["veracrypt", "--unmount"], capture_output=True, check=True)
540
+ temp_dir.cleanup()
541
+
542
+ return make_subprocess_checker(*args)
543
+
544
+
545
+ def check_passwords_sequentially(
546
+ candidates: Iterable[tuple[str, int]],
547
+ test_func: Callable[[str], bool],
548
+ verbosity: int = 0,
549
+ update_every: int | None = None,
550
+ total: int | None = None,
551
+ print_passwords: bool = False,
552
+ **kwargs,
553
+ ) -> tuple[str, int] | None:
554
+
555
+ out_of_total = "" if total is None else f"/{total}"
556
+
557
+ if update_every is None:
558
+ update_every = 40 if total is None else max(1, total // 300)
559
+
560
+ if verbosity >= 1:
561
+ update_every = min(update_every, 1000)
562
+
563
+ last_printed_num_subs = 0
564
+ for i, (candidate, num_subs) in enumerate(candidates, start=1):
565
+ if test_func(candidate):
566
+ return candidate, i
567
+
568
+ if i % update_every:
569
+ continue
570
+ if verbosity == 0:
571
+ print_to_stderr(".", end="", flush=True)
572
+ # If testing multiple guesses at the same time, the current
573
+ # number of substitutions for each might not be synchronised.
574
+ if num_subs > last_printed_num_subs:
575
+ print_to_stderr(
576
+ f"Now testing candidates formed by {num_subs} substitutions from guess"
577
+ )
578
+ last_printed_num_subs = num_subs
579
+ elif verbosity >= 2 and print_passwords:
580
+ print_to_stderr(
581
+ f"{i}{out_of_total}) tried: {candidate} (num substitutions={num_subs})",
582
+ flush=True,
583
+ )
584
+ else:
585
+ print_to_stderr(
586
+ f"{i}{out_of_total}, num substitutions={num_subs}", flush=True
587
+ )
588
+
589
+ return None
590
+
591
+
592
+ default_password_protected_file_checker_factories = {
593
+ ".7z": make_7zip_checker,
594
+ ".json": make_py_avdu_aegis_checker,
595
+ ".kdbx": make_pykeepass_checker,
596
+ ".kdb": make_pykeepass_checker,
597
+ ".pem": make_ssh_key_checker, # could make this the legacy PEM one?
598
+ ".key": make_ssh_key_checker,
599
+ ".priv": make_ssh_key_checker,
600
+ ".docx": make_MS_Office_files_key_checker,
601
+ ".xlsx": make_MS_Office_files_key_checker,
602
+ ".hc": make_Veracrypt_checker,
603
+ ".tc": make_Veracrypt_checker,
604
+ }
605
+
606
+
607
+ def _default_factory_selector(*args: str):
608
+ if not args:
609
+ raise ValueError(
610
+ "Default checker requires arg(s) to define how to test the passwords"
611
+ )
612
+
613
+ path = Path(args[0])
614
+
615
+ if len(args) == 1 and path.is_file():
616
+ return default_password_protected_file_checker_factories[path.suffix.lower()]
617
+
618
+ return make_subprocess_checker
619
+
620
+
621
+ parser = argparse.ArgumentParser(prog="fiddlesticks")
622
+ parser.suggest_on_error = True # type: ignore
623
+ parser.add_argument(
624
+ "--max-subs",
625
+ "-N",
626
+ type=int,
627
+ default=2,
628
+ help=(
629
+ "The maximum number of character substitutions "
630
+ "that will be applied to the guess"
631
+ ),
632
+ )
633
+ parser.add_argument("--verbosity", "-v", action="count", default=0)
634
+ parser.add_argument(
635
+ "--update-every",
636
+ "-V",
637
+ type=int,
638
+ default=None,
639
+ help=(
640
+ "How many candidates to test before printing an update message "
641
+ "to stderr (no effect without -v). "
642
+ ),
643
+ )
644
+ parser.add_argument(
645
+ "--output-file",
646
+ "-o",
647
+ type=str,
648
+ default="",
649
+ help="File to write found passwords to.",
650
+ )
651
+ parser.add_argument(
652
+ "--print-passwords",
653
+ "-P",
654
+ action="store_true",
655
+ help=(
656
+ "Set this option to print passwords to stderr. "
657
+ "By default, neither found passwords (nor candidates if -vv) are printed. "
658
+ "E.g. if extracting a password-protected archive as a side-effect is sufficient "
659
+ "(and you intend to re-encrypt it with a different password anyway)."
660
+ ),
661
+ )
662
+ parser.add_argument(
663
+ "--password-guess",
664
+ "-p",
665
+ action="append",
666
+ dest="password_guesses",
667
+ default=[],
668
+ help=(
669
+ "WARNING! Password guesses given on the command line may be saved by your shell, "
670
+ "and e.g. appear in the Bash history file. Otherwise, you will be prompted to "
671
+ "securely enter the guess(es) before the search can begin. "
672
+ ),
673
+ )
674
+ parser.add_argument(
675
+ "--input-file",
676
+ "-i",
677
+ required=False,
678
+ type=Path,
679
+ help="Optional file of password guesses. ",
680
+ )
681
+ parser.add_argument(
682
+ "--extract-to",
683
+ "-x",
684
+ default=None,
685
+ help=(
686
+ "The dir to try to extract archives in "
687
+ "(if using an external program as the password checker)"
688
+ ),
689
+ )
690
+ parser.add_argument(
691
+ "extras",
692
+ type=str,
693
+ nargs="*",
694
+ action="extend",
695
+ help=(
696
+ "Extra args to create the password checker with. "
697
+ "E.g. file to find password for, "
698
+ "or partial shell command, "
699
+ "to which the password guesses will be appended, "
700
+ "such as: 7z x archive.7z -p"
701
+ ),
702
+ )
703
+
704
+ parser.set_defaults(
705
+ command=None,
706
+ password_generator=candidate_passwords_from_alt_chars,
707
+ alt_char_map=None,
708
+ )
709
+
710
+
711
+ def add_mutex_group(
712
+ title: str | None = None,
713
+ description: str | None = None,
714
+ required: bool = False,
715
+ ):
716
+ arg_group = parser.add_argument_group(title=title, description=description)
717
+ mutex_arg_group = arg_group.add_mutually_exclusive_group(required=required)
718
+ return mutex_arg_group
719
+
720
+
721
+ command_args_group = add_mutex_group(
722
+ "Sub-command",
723
+ (
724
+ "The sub-commmand (if any), e.g. the Password checker to use to test candidates. "
725
+ "If not set, the sub-command is inferred from the file extension "
726
+ "of any archive file (if present). "
727
+ "Otherwise a partial external shell command is expected, to which candidates can be appended. "
728
+ ),
729
+ )
730
+
731
+
732
+ def add_command_arg(name, command, help: str | None = None):
733
+ command_args_group.add_argument(
734
+ name,
735
+ dest="command",
736
+ action="store_const",
737
+ const=command,
738
+ help=help,
739
+ )
740
+
741
+
742
+ add_command_arg("--shell", make_subprocess_checker)
743
+ add_command_arg("--7zip", make_7zip_checker)
744
+ add_command_arg("--7zip-persistent", make_persistent_7zip_checker)
745
+ add_command_arg(
746
+ "--pipe",
747
+ make_password_candidate_piper,
748
+ help=(
749
+ "Print all password candidates to stdout, "
750
+ "e.g. to pipe them to an external password checking program. "
751
+ "Overrides --print-passwords. "
752
+ ),
753
+ )
754
+ add_command_arg("--print-char-map", "print-char-map")
755
+ # Optional commands requiring extra deps
756
+ add_command_arg("--ssh", make_ssh_key_checker)
757
+ add_command_arg("--openssh", make_openSSH_key_checker)
758
+ add_command_arg("--ssh-pem", make_ssh_pem_key_checker)
759
+ add_command_arg("--keypassxc", make_pykeepass_checker)
760
+ add_command_arg("--aegis", make_py_avdu_aegis_checker)
761
+ add_command_arg("--py7zr", make_py7zr_checker)
762
+ add_command_arg("--msoffice", make_MS_Office_files_key_checker)
763
+ add_command_arg("--veracrypt", make_Veracrypt_checker)
764
+
765
+
766
+ alt_char_map_group = add_mutex_group(
767
+ "Character map",
768
+ (
769
+ "The mapping for alternative characters, "
770
+ "to be used to generate candidate passwords from. "
771
+ ),
772
+ )
773
+
774
+ alt_char_map_group.add_argument(
775
+ "--shift_and_leet",
776
+ dest="alt_char_map",
777
+ action="store_const",
778
+ const=SHIFT_AND_LEET_BI_MAP,
779
+ )
780
+
781
+ alt_char_map_group.add_argument(
782
+ "--char-map",
783
+ type=Path,
784
+ help=(
785
+ "A JSON file containing a mapping of characters "
786
+ "(length 1 strings) to alternative characters."
787
+ ),
788
+ )
789
+
790
+
791
+ def cli(args: list[str] = sys.argv[1:]) -> int:
792
+
793
+ if not args:
794
+ parser.print_help(file=sys.stderr)
795
+ return 0
796
+
797
+ ns = parser.parse_args(args)
798
+ kwargs = vars(ns).copy()
799
+
800
+ alt_char_map: defaultdict[str, list[str]]
801
+ if ns.alt_char_map is None:
802
+ if ns.char_map is not None:
803
+ alt_char_map = defaultdict(list)
804
+ alt_char_map.update(json.loads(ns.char_map.read_text()))
805
+ else:
806
+ alt_char_map = SHIFT_AND_LEET_BI_MAP
807
+ else:
808
+ alt_char_map = ns.alt_char_map
809
+
810
+ if ns.command == "print-char-map":
811
+ # Prettified JSON, without adding a new line for each item in an array
812
+ # (unlike json.dumps(..., indent = 4))
813
+ items = iter(alt_char_map.items())
814
+ k, v = next(items)
815
+ print_to_stderr("{" + f"{json.dumps(k)}: {json.dumps(v)}", end="")
816
+ for k, v in items:
817
+ print_to_stderr(f",\n {json.dumps(k)}: {json.dumps(v)}", end="")
818
+ print_to_stderr("\n}")
819
+ return 0
820
+
821
+ password_guesses = kwargs.pop("password_guesses", [])
822
+
823
+ if password_guesses:
824
+ warnings.warn(
825
+ "Password guesses given on command line may be stored in history. "
826
+ "After this program ends, you may wish to delete the latest history entry, "
827
+ "e.g. by running: history -d $(history 1 | awk '{print $1}')"
828
+ )
829
+
830
+ input_file = kwargs.pop("input_file", None)
831
+ if input_file is not None:
832
+ password_guesses.extend(input_file.read_text().splitlines())
833
+
834
+ if not password_guesses:
835
+ while password_guess := getpass.getpass(
836
+ "Input password guess (or press Enter when done): "
837
+ ):
838
+ password_guesses.append(password_guess)
839
+
840
+ extras = kwargs.pop("extras")
841
+
842
+ if ns.command is None:
843
+ command = _default_factory_selector(*extras)
844
+ else:
845
+ command = ns.command
846
+
847
+ if (
848
+ command
849
+ not in (
850
+ make_7zip_checker,
851
+ make_persistent_7zip_checker,
852
+ make_subprocess_checker,
853
+ make_password_candidate_piper,
854
+ )
855
+ and not ns.print_passwords
856
+ and not ns.output_file
857
+ and ns.verbosity == 0
858
+ ):
859
+ warnings.warn(
860
+ "The SSH key, Keepass and the Aegis vault checkers do not decrypt files. "
861
+ "When running fiddlesticks without print-passwords, without "
862
+ "an output-file, and with verbosity=0, only the candidate number "
863
+ "of any recovered password will be printed. "
864
+ )
865
+
866
+ total, candidates = ns.password_generator(
867
+ guesses=password_guesses, max_subs=ns.max_subs, alt_char_map=alt_char_map
868
+ )
869
+ checker = command(*extras, **kwargs)
870
+
871
+ t0 = time.time()
872
+
873
+ result = check_passwords_sequentially(candidates, checker, total=total, **kwargs)
874
+
875
+ t1 = time.time()
876
+
877
+ if result is None:
878
+ if ns.command is make_password_candidate_piper:
879
+ return 0
880
+
881
+ print_to_stderr(
882
+ "\n\nCould not find password. Try a different guess, or increasing max substitutions (-N) ? "
883
+ )
884
+ return 1
885
+
886
+ password, i = result
887
+
888
+ possibly_output_found_password(password, i, t1 - t0, **kwargs)
889
+
890
+ return 0
891
+
892
+
893
+ if __name__ == "__main__":
894
+ cli()