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
|
+

|
|
26
|
+

|
|
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,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()
|