fiddlesticks 0.0.0__tar.gz → 0.2.0__tar.gz
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.
- fiddlesticks-0.2.0/.gitattributes +3 -0
- fiddlesticks-0.2.0/.github/workflows/tests.yml +38 -0
- {fiddlesticks-0.0.0 → fiddlesticks-0.2.0}/.gitignore +3 -0
- {fiddlesticks-0.0.0 → fiddlesticks-0.2.0}/PKG-INFO +49 -19
- {fiddlesticks-0.0.0 → fiddlesticks-0.2.0}/README.md +44 -18
- {fiddlesticks-0.0.0 → fiddlesticks-0.2.0}/pyproject.toml +4 -1
- {fiddlesticks-0.0.0 → fiddlesticks-0.2.0}/src/fiddlesticks.py +76 -22
- fiddlesticks-0.2.0/tests/Test_vault_Do_Not_Use.kdbx +0 -0
- fiddlesticks-0.2.0/tests/__init__.py +0 -0
- fiddlesticks-0.2.0/tests/checker_tests.py +29 -0
- fiddlesticks-0.2.0/tests/end_to_end_tests.py +252 -0
- fiddlesticks-0.2.0/tests/helpers.py +166 -0
- fiddlesticks-0.2.0/tests/pw_generator_tests.py +24 -0
- {fiddlesticks-0.0.0 → fiddlesticks-0.2.0}/tests/smoke_tests.py +2 -1
- fiddlesticks-0.0.0/.gitattributes +0 -1
- fiddlesticks-0.0.0/.hypothesis/.gitignore +0 -9
- fiddlesticks-0.0.0/.hypothesis/constants/628818e30619470c +0 -4
- fiddlesticks-0.0.0/.hypothesis/unicode_data/16.0.0/charmap.json.gz +0 -0
- fiddlesticks-0.0.0/tests/end_to_end_tests.py +0 -189
- {fiddlesticks-0.0.0 → fiddlesticks-0.2.0}/CANARY +0 -0
- {fiddlesticks-0.0.0 → fiddlesticks-0.2.0}/LICENSE +0 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
push:
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
test-case: [
|
|
14
|
+
"test_is_7zip_installed",
|
|
15
|
+
"test_alt_chars_candidates_generator",
|
|
16
|
+
"test_7z_archives_extracted_via_fiddlesticks_CLI",
|
|
17
|
+
"test_piping_candidates_to_stdout",
|
|
18
|
+
"test_aegis_checker_from_CLI",
|
|
19
|
+
"test_aegis_checker_errors_from_CLI",
|
|
20
|
+
"test_aegis_checker_against_avdu_vault",
|
|
21
|
+
"test_pykeepass_checker_against_Test_vault",
|
|
22
|
+
]
|
|
23
|
+
steps:
|
|
24
|
+
- name: Check out repo
|
|
25
|
+
uses: actions/checkout@v7
|
|
26
|
+
|
|
27
|
+
- name: Set up Python
|
|
28
|
+
uses: actions/setup-python@v7
|
|
29
|
+
with:
|
|
30
|
+
python-version: "3.14"
|
|
31
|
+
|
|
32
|
+
- name: Install dependencies
|
|
33
|
+
run: |
|
|
34
|
+
pip install -e .[tests,py7zr,aegis,keepassxc]
|
|
35
|
+
|
|
36
|
+
- name: Run PyTest
|
|
37
|
+
run: |
|
|
38
|
+
pytest -k ${{ matrix.test-case }}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: fiddlesticks
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Password recovery tool for encrypted archives (currently only .7z files supported).
|
|
5
5
|
Project-URL: GitHub, https://github.com/Hazardous-Area/fiddlesticks
|
|
6
6
|
Author-email: James Parrott <james@jamesparrott.dev>
|
|
7
7
|
License-Expression: MIT
|
|
8
8
|
License-File: LICENSE
|
|
9
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'
|
|
10
14
|
Provides-Extra: py7zr
|
|
11
15
|
Requires-Dist: py7zr; extra == 'py7zr'
|
|
12
16
|
Provides-Extra: tests
|
|
@@ -16,20 +20,31 @@ Description-Content-Type: text/markdown
|
|
|
16
20
|
|
|
17
21
|
# Fiddlesticks!
|
|
18
22
|
*"Aaaagh! I forgot my 7zip password"* - James (more times than he cares to remember).
|
|
23
|
+

|
|
24
|
+
|
|
25
|
+
Version 0.2.0
|
|
19
26
|
|
|
20
27
|
## Description
|
|
21
28
|
Password recovery tool, for password-encrypted files, using simple off-line brute
|
|
22
29
|
force attacks. Password candidates are generated, using common variations
|
|
23
30
|
of a guessed password (e.g. typos and substitutions).
|
|
24
31
|
|
|
25
|
-
###
|
|
32
|
+
### Raison d'etre
|
|
26
33
|
- Password-protected file owners recovering their own password themselves, as long as
|
|
27
34
|
they can still recall a rough guess for their password, might only need to test
|
|
28
35
|
every candidate password that's similar enough to the guess.
|
|
29
36
|
- This may be a much faster and cheaper computation
|
|
30
|
-
than the one an adversary must do, without such a guess, but in
|
|
37
|
+
than the one an adversary must do, without such a guess, but in possession
|
|
31
38
|
of a stolen password protected file[^0].
|
|
32
39
|
|
|
40
|
+
### Warning
|
|
41
|
+
Strictly speaking, Fiddlesticks is a password-protected file recovery tool. Use it to
|
|
42
|
+
get your files back. But once you've found a password that protected those files via Fiddlesticks
|
|
43
|
+
(or any third party tool) it should not be used again. Anywhere else it is also used, the
|
|
44
|
+
password should be reset (or the files re-encrypted with a different one). By default,
|
|
45
|
+
Fiddlesticks does not print the password it finds (or any candidates) unless `-P` or `-v` is set
|
|
46
|
+
(or if using `--pipe` with no pipe).
|
|
47
|
+
|
|
33
48
|
### "Back of envelope" sketch 'calculation'
|
|
34
49
|
- Attackers targetting a truly[^0] random password, must try up to `2**N`
|
|
35
50
|
candidate passwords (for each bit length `N` being considered).
|
|
@@ -46,30 +61,45 @@ archive can also do so - the password wasn't strong enough.
|
|
|
46
61
|
(e.g. this could indicate that the starting guess was wrong).
|
|
47
62
|
|
|
48
63
|
### Design and security notes
|
|
64
|
+
*"FAQ: Why the heck should anyone in their right mind trust this with their password?"*
|
|
49
65
|
- Any similar 3rd party password cracking service based on 'best guess' passwords, requires
|
|
50
66
|
the user to share the guesses for their passwords with the service. Even if the password
|
|
51
67
|
was not used for anything else, sharing even guesses for secret credentials with 3rd parties,
|
|
52
68
|
is a critical security issue.
|
|
53
|
-
- Fiddlesticks is designed to minimise the need for this. It is designed to
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
- Fiddlesticks is designed to minimise the need for this. It is designed to i) be as easy
|
|
70
|
+
to install as possible, and in particular ii) require as few dependencies as possible.
|
|
71
|
+
Firstly, the intention of i) is to assist users to run Fiddlesticks in their own secure
|
|
72
|
+
environment, locked down as much as they want (e.g. offline and disconnected from
|
|
73
|
+
all external network access), without requiring them to take their
|
|
74
|
+
password guesses outside of that. For example, for recovery of password encrypted .7z archives,
|
|
75
|
+
only a normal installation of 7zip is required on Linux (plus a close enough guess of the password!).
|
|
76
|
+
Fiddlesticks can even generate a file of candidate passwords, for external programs, and other
|
|
77
|
+
possible applications. Secondly, ii) helps users decide for themselves whether or not to trust Fiddlesticks
|
|
78
|
+
in the first place. In particular whether or not it will take their password guesses outside
|
|
79
|
+
of its running environment. When the project was concieved, the intention was also to
|
|
80
|
+
iii) require as little code as possible. But the code base has since become somewhat more
|
|
81
|
+
complex, mainly to have a nice CLI. Simplicity and brevity should both be much
|
|
82
|
+
more highly prized features of software in general. But you
|
|
83
|
+
be the judge of whether or not c) is still the case. The application code is all in a 526 line file.
|
|
84
|
+
|
|
85
|
+
### Usage
|
|
86
|
+
Recommended use is simply to automate attempts to open a 7z archive via the user's own 7zip.
|
|
87
|
+
Fiddlesticks can also try to decrypt KeepassXC vaults and Aegis vault files (for TOTP authenticators) if
|
|
88
|
+
[pykeepass](https://pypi.org/project/pykeepass/) and [py-avdu](https://pypi.org/project/py-avdu/) respectively, are
|
|
89
|
+
also installed.
|
|
90
|
+
|
|
91
|
+
There are a couple of alternative modes too, firstly: automating any other external Bash command that a candidate password can be appended to (that obeys the normal return code convention).
|
|
92
|
+
|
|
93
|
+
Secondly with `--pipe` candidate passwords can be sent to stdout, from where they can be piped to a user's own external program or code (all the normal output from fiddlesticks goes to stderr).
|
|
64
94
|
|
|
65
|
-
|
|
66
|
-
- There are a couple of alternative modes too, firstly: automating any other external Bash command that a candidate password can be appended to (that obeys the normal return code convention).
|
|
67
|
-
- Secondly candidate passwords can be sent to stdout, from where they can be piped to a
|
|
68
|
-
user's own external program or code (all the normal output from fiddlesticks goes to stderr).
|
|
69
|
-
- Thirdly, if py7zr is also installed, fiddlesticks can use it to test passwords for 7z archived
|
|
95
|
+
Thirdly, if py7zr is also installed, with `--py7zr` fiddlesticks can use it to test passwords for 7z archives,
|
|
70
96
|
entirely within Python.
|
|
71
97
|
|
|
72
98
|
### Other Notes
|
|
99
|
+
- Successful attempts to extract a password-protected archive, result in the archive being
|
|
100
|
+
unencrypted (naturally) by some methods. Currently all such plaintext unencrypted archives
|
|
101
|
+
are not deleted from the file system afterwards. If the options `--extract-to` or `-x`
|
|
102
|
+
are given, archives are extracted there.
|
|
73
103
|
- Fiddlesticks cannot recover passwords for online accounts. Online password entry attempts
|
|
74
104
|
should be rate limited. Cracking is only possible locally if the website owner shares the
|
|
75
105
|
password hash with the user, in which case they can probably provide the rest of their
|
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
# Fiddlesticks!
|
|
2
2
|
*"Aaaagh! I forgot my 7zip password"* - James (more times than he cares to remember).
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
Version 0.2.0
|
|
3
6
|
|
|
4
7
|
## Description
|
|
5
8
|
Password recovery tool, for password-encrypted files, using simple off-line brute
|
|
6
9
|
force attacks. Password candidates are generated, using common variations
|
|
7
10
|
of a guessed password (e.g. typos and substitutions).
|
|
8
11
|
|
|
9
|
-
###
|
|
12
|
+
### Raison d'etre
|
|
10
13
|
- Password-protected file owners recovering their own password themselves, as long as
|
|
11
14
|
they can still recall a rough guess for their password, might only need to test
|
|
12
15
|
every candidate password that's similar enough to the guess.
|
|
13
16
|
- This may be a much faster and cheaper computation
|
|
14
|
-
than the one an adversary must do, without such a guess, but in
|
|
17
|
+
than the one an adversary must do, without such a guess, but in possession
|
|
15
18
|
of a stolen password protected file[^0].
|
|
16
19
|
|
|
20
|
+
### Warning
|
|
21
|
+
Strictly speaking, Fiddlesticks is a password-protected file recovery tool. Use it to
|
|
22
|
+
get your files back. But once you've found a password that protected those files via Fiddlesticks
|
|
23
|
+
(or any third party tool) it should not be used again. Anywhere else it is also used, the
|
|
24
|
+
password should be reset (or the files re-encrypted with a different one). By default,
|
|
25
|
+
Fiddlesticks does not print the password it finds (or any candidates) unless `-P` or `-v` is set
|
|
26
|
+
(or if using `--pipe` with no pipe).
|
|
27
|
+
|
|
17
28
|
### "Back of envelope" sketch 'calculation'
|
|
18
29
|
- Attackers targetting a truly[^0] random password, must try up to `2**N`
|
|
19
30
|
candidate passwords (for each bit length `N` being considered).
|
|
@@ -30,30 +41,45 @@ archive can also do so - the password wasn't strong enough.
|
|
|
30
41
|
(e.g. this could indicate that the starting guess was wrong).
|
|
31
42
|
|
|
32
43
|
### Design and security notes
|
|
44
|
+
*"FAQ: Why the heck should anyone in their right mind trust this with their password?"*
|
|
33
45
|
- Any similar 3rd party password cracking service based on 'best guess' passwords, requires
|
|
34
46
|
the user to share the guesses for their passwords with the service. Even if the password
|
|
35
47
|
was not used for anything else, sharing even guesses for secret credentials with 3rd parties,
|
|
36
48
|
is a critical security issue.
|
|
37
|
-
- Fiddlesticks is designed to minimise the need for this. It is designed to
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
- Fiddlesticks is designed to minimise the need for this. It is designed to i) be as easy
|
|
50
|
+
to install as possible, and in particular ii) require as few dependencies as possible.
|
|
51
|
+
Firstly, the intention of i) is to assist users to run Fiddlesticks in their own secure
|
|
52
|
+
environment, locked down as much as they want (e.g. offline and disconnected from
|
|
53
|
+
all external network access), without requiring them to take their
|
|
54
|
+
password guesses outside of that. For example, for recovery of password encrypted .7z archives,
|
|
55
|
+
only a normal installation of 7zip is required on Linux (plus a close enough guess of the password!).
|
|
56
|
+
Fiddlesticks can even generate a file of candidate passwords, for external programs, and other
|
|
57
|
+
possible applications. Secondly, ii) helps users decide for themselves whether or not to trust Fiddlesticks
|
|
58
|
+
in the first place. In particular whether or not it will take their password guesses outside
|
|
59
|
+
of its running environment. When the project was concieved, the intention was also to
|
|
60
|
+
iii) require as little code as possible. But the code base has since become somewhat more
|
|
61
|
+
complex, mainly to have a nice CLI. Simplicity and brevity should both be much
|
|
62
|
+
more highly prized features of software in general. But you
|
|
63
|
+
be the judge of whether or not c) is still the case. The application code is all in a 526 line file.
|
|
64
|
+
|
|
65
|
+
### Usage
|
|
66
|
+
Recommended use is simply to automate attempts to open a 7z archive via the user's own 7zip.
|
|
67
|
+
Fiddlesticks can also try to decrypt KeepassXC vaults and Aegis vault files (for TOTP authenticators) if
|
|
68
|
+
[pykeepass](https://pypi.org/project/pykeepass/) and [py-avdu](https://pypi.org/project/py-avdu/) respectively, are
|
|
69
|
+
also installed.
|
|
70
|
+
|
|
71
|
+
There are a couple of alternative modes too, firstly: automating any other external Bash command that a candidate password can be appended to (that obeys the normal return code convention).
|
|
72
|
+
|
|
73
|
+
Secondly with `--pipe` candidate passwords can be sent to stdout, from where they can be piped to a user's own external program or code (all the normal output from fiddlesticks goes to stderr).
|
|
48
74
|
|
|
49
|
-
|
|
50
|
-
- There are a couple of alternative modes too, firstly: automating any other external Bash command that a candidate password can be appended to (that obeys the normal return code convention).
|
|
51
|
-
- Secondly candidate passwords can be sent to stdout, from where they can be piped to a
|
|
52
|
-
user's own external program or code (all the normal output from fiddlesticks goes to stderr).
|
|
53
|
-
- Thirdly, if py7zr is also installed, fiddlesticks can use it to test passwords for 7z archived
|
|
75
|
+
Thirdly, if py7zr is also installed, with `--py7zr` fiddlesticks can use it to test passwords for 7z archives,
|
|
54
76
|
entirely within Python.
|
|
55
77
|
|
|
56
78
|
### Other Notes
|
|
79
|
+
- Successful attempts to extract a password-protected archive, result in the archive being
|
|
80
|
+
unencrypted (naturally) by some methods. Currently all such plaintext unencrypted archives
|
|
81
|
+
are not deleted from the file system afterwards. If the options `--extract-to` or `-x`
|
|
82
|
+
are given, archives are extracted there.
|
|
57
83
|
- Fiddlesticks cannot recover passwords for online accounts. Online password entry attempts
|
|
58
84
|
should be rate limited. Cracking is only possible locally if the website owner shares the
|
|
59
85
|
password hash with the user, in which case they can probably provide the rest of their
|
|
@@ -16,8 +16,11 @@ dynamic=["version"]
|
|
|
16
16
|
dependencies = []
|
|
17
17
|
|
|
18
18
|
[project.optional-dependencies]
|
|
19
|
+
py7zr = ["py7zr"]
|
|
20
|
+
aegis = ["py-avdu"]
|
|
21
|
+
keepassxc = ["pykeepass"]
|
|
19
22
|
tests = ["pytest", "hypothesis"]
|
|
20
|
-
|
|
23
|
+
|
|
21
24
|
|
|
22
25
|
[project.urls]
|
|
23
26
|
GitHub = "https://github.com/Hazardous-Area/fiddlesticks"
|
|
@@ -2,29 +2,30 @@
|
|
|
2
2
|
# requires-python = ">=3.12"
|
|
3
3
|
# dependencies = []
|
|
4
4
|
# optional_dependencies = [
|
|
5
|
-
# py7zr = ["py7zr"
|
|
5
|
+
# py7zr = ["py7zr"],
|
|
6
|
+
# aegis = ["py-avdu"],
|
|
7
|
+
# keepassxc = ["pykeepass"],
|
|
8
|
+
# tests = ["pytest", "hypothesis"],
|
|
6
9
|
# ]
|
|
7
10
|
# ///
|
|
8
11
|
|
|
9
|
-
__version__ = "0.
|
|
12
|
+
__version__ = "0.2.0"
|
|
10
13
|
|
|
11
14
|
import argparse
|
|
12
15
|
import atexit
|
|
13
16
|
import collections
|
|
14
|
-
import functools
|
|
15
17
|
import getpass
|
|
16
18
|
import io
|
|
17
19
|
import itertools
|
|
18
|
-
from pathlib import Path
|
|
19
20
|
import string
|
|
20
21
|
import subprocess
|
|
21
22
|
import sys
|
|
22
23
|
import tempfile
|
|
23
24
|
import textwrap
|
|
24
25
|
import time
|
|
25
|
-
from typing import Iterator, Iterable, Collection, Hashable, Callable
|
|
26
26
|
import warnings
|
|
27
|
-
|
|
27
|
+
from collections.abc import Callable, Hashable, Iterable, Iterator
|
|
28
|
+
from pathlib import Path
|
|
28
29
|
|
|
29
30
|
TMP_DIR = Path(tempfile.gettempdir()) / "fiddlesticks"
|
|
30
31
|
TMP_DIR.mkdir(exist_ok=True)
|
|
@@ -144,18 +145,24 @@ def candidate_passwords_from_alt_chars(
|
|
|
144
145
|
|
|
145
146
|
def make_py7zr_checker(archive: str, extract_to: str | None = None, **kwargs):
|
|
146
147
|
import py7zr
|
|
148
|
+
exceptions = (py7zr.exceptions.PasswordRequired, py7zr.exceptions.Bad7zFile)
|
|
149
|
+
try:
|
|
150
|
+
import _lzma
|
|
151
|
+
except ImportError:
|
|
152
|
+
pass
|
|
153
|
+
else:
|
|
154
|
+
exceptions += ( _lzma.LZMAError,)
|
|
147
155
|
if extract_to is None:
|
|
148
156
|
extract_to = str(_make_new_tmp_sub_dir(archive))
|
|
149
157
|
stream = io.BytesIO(Path(archive).read_bytes())
|
|
150
|
-
def is_correct_password_for_7z_file(candidate: str
|
|
158
|
+
def is_correct_password_for_7z_file(candidate: str) -> bool:
|
|
151
159
|
stream.seek(0)
|
|
152
160
|
try:
|
|
153
161
|
f = py7zr.SevenZipFile(stream, 'r', password=candidate)
|
|
154
162
|
f.extractall(path=extract_to)
|
|
155
|
-
except
|
|
163
|
+
except exceptions:
|
|
156
164
|
return False
|
|
157
|
-
|
|
158
|
-
f.close()
|
|
165
|
+
f.close()
|
|
159
166
|
return True
|
|
160
167
|
return is_correct_password_for_7z_file
|
|
161
168
|
|
|
@@ -163,7 +170,7 @@ def make_py7zr_checker(archive: str, extract_to: str | None = None, **kwargs):
|
|
|
163
170
|
def make_subprocess_checker(*args: str, **kwargs):
|
|
164
171
|
*rest, last = args
|
|
165
172
|
def is_correct_password(candidate: str) -> bool:
|
|
166
|
-
result = subprocess.run([*rest, f"{last}{candidate}"], capture_output=True)
|
|
173
|
+
result = subprocess.run([*rest, f"{last}{candidate}"], capture_output=True, check=False)
|
|
167
174
|
return result.returncode==0
|
|
168
175
|
return is_correct_password
|
|
169
176
|
|
|
@@ -181,7 +188,7 @@ class ProgramNotFound(Exception): pass
|
|
|
181
188
|
|
|
182
189
|
def make_7zip_checker(file: str, extract_to: str | None = None, **kwargs):
|
|
183
190
|
args = ["7z", "--help"]
|
|
184
|
-
result =subprocess.run(args, capture_output=True)
|
|
191
|
+
result = subprocess.run(args, capture_output=True, check=False)
|
|
185
192
|
if result.returncode != 0:
|
|
186
193
|
raise ProgramNotFound(f"7zip. Args: {args}")
|
|
187
194
|
|
|
@@ -190,19 +197,16 @@ def make_7zip_checker(file: str, extract_to: str | None = None, **kwargs):
|
|
|
190
197
|
|
|
191
198
|
return make_subprocess_checker("7z","x", f"-o{extract_to}", file, "-p")
|
|
192
199
|
|
|
193
|
-
def make_password_candidate_piper(**kwargs):
|
|
200
|
+
def make_password_candidate_piper(*args, **kwargs):
|
|
194
201
|
def piper(password: str):
|
|
195
202
|
print(password, file=sys.stdout)
|
|
196
203
|
return False
|
|
197
204
|
return piper
|
|
198
205
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
extract_to = str(_make_new_tmp_sub_dir(file))
|
|
206
|
+
PERSISTENT_7Z_CHECKER_OUTLINE = """\
|
|
207
|
+
#!/usr/bin/env bash
|
|
202
208
|
|
|
203
|
-
|
|
204
|
-
while read -r line; do
|
|
205
|
-
|
|
209
|
+
while read -r line; do
|
|
206
210
|
# Silently run command, only check exit code
|
|
207
211
|
7z x -o{extract_to} {file} -p"$line" > /dev/null 2>&1
|
|
208
212
|
if [ $? -eq 0 ]; then
|
|
@@ -210,8 +214,14 @@ def make_persistent_7zip_checker(file: str, extract_to: str | None = None, **kwa
|
|
|
210
214
|
break
|
|
211
215
|
fi
|
|
212
216
|
echo "Nope :("
|
|
213
|
-
|
|
214
|
-
|
|
217
|
+
done
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def make_persistent_7zip_checker(file: str, extract_to: str | None = None, **kwargs):
|
|
221
|
+
if extract_to is None:
|
|
222
|
+
extract_to = str(_make_new_tmp_sub_dir(file))
|
|
223
|
+
|
|
224
|
+
cmd = textwrap.dedent(PERSISTENT_7Z_CHECKER_OUTLINE.format(extract_to=extract_to, file=file))
|
|
215
225
|
|
|
216
226
|
# Launch a single persistent Bash process reading from stdin line-by-line
|
|
217
227
|
proc = subprocess.Popen(
|
|
@@ -239,6 +249,42 @@ def make_persistent_7zip_checker(file: str, extract_to: str | None = None, **kwa
|
|
|
239
249
|
return checker
|
|
240
250
|
|
|
241
251
|
|
|
252
|
+
def make_py_avdu_aegis_checker(file: str, **kwargs):
|
|
253
|
+
|
|
254
|
+
import json
|
|
255
|
+
|
|
256
|
+
from py_avdu.encrypted_classes import VaultEncrypted
|
|
257
|
+
|
|
258
|
+
vault_dict = json.loads(Path(file).read_text())
|
|
259
|
+
|
|
260
|
+
encrypted = VaultEncrypted(**vault_dict)
|
|
261
|
+
|
|
262
|
+
def checker(candidate: str) -> bool:
|
|
263
|
+
try:
|
|
264
|
+
encrypted.find_master_key(candidate)
|
|
265
|
+
return True
|
|
266
|
+
except ValueError:
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
return checker
|
|
271
|
+
|
|
272
|
+
def make_pykeepass_checker(file: str, **kwargs):
|
|
273
|
+
|
|
274
|
+
from pykeepass import PyKeePass
|
|
275
|
+
from pykeepass.exceptions import CredentialsError
|
|
276
|
+
|
|
277
|
+
def checker(candidate: str) -> bool:
|
|
278
|
+
try:
|
|
279
|
+
PyKeePass(file, password=candidate)
|
|
280
|
+
return True
|
|
281
|
+
except CredentialsError:
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
return checker
|
|
286
|
+
|
|
287
|
+
|
|
242
288
|
def test_passwords_sequentially(
|
|
243
289
|
candidates: Iterable[str],
|
|
244
290
|
test_func: Callable[[str], bool],
|
|
@@ -274,6 +320,9 @@ def test_passwords_sequentially(
|
|
|
274
320
|
|
|
275
321
|
default_password_protected_file_checker_factories = {
|
|
276
322
|
".7z" : make_7zip_checker,
|
|
323
|
+
".json" : make_py_avdu_aegis_checker,
|
|
324
|
+
".kdbx" : make_pykeepass_checker,
|
|
325
|
+
".kdb" : make_pykeepass_checker,
|
|
277
326
|
}
|
|
278
327
|
|
|
279
328
|
|
|
@@ -398,6 +447,8 @@ add_checker_factory_arg("--7zip", make_7zip_checker)
|
|
|
398
447
|
add_checker_factory_arg("--7zip-persistent", make_persistent_7zip_checker)
|
|
399
448
|
add_checker_factory_arg("--shell", make_subprocess_checker)
|
|
400
449
|
add_checker_factory_arg("--py7zr", make_py7zr_checker)
|
|
450
|
+
add_checker_factory_arg("--aegis", make_py_avdu_aegis_checker)
|
|
451
|
+
add_checker_factory_arg("--keypassxc", make_pykeepass_checker)
|
|
401
452
|
add_checker_factory_arg(
|
|
402
453
|
"--pipe",
|
|
403
454
|
make_password_candidate_piper,
|
|
@@ -453,7 +504,10 @@ def cli(args: list[str] = sys.argv[1:]) -> int:
|
|
|
453
504
|
t1 = time.time()
|
|
454
505
|
|
|
455
506
|
if result is None:
|
|
456
|
-
|
|
507
|
+
if namespace.checker_factory is make_password_candidate_piper:
|
|
508
|
+
return 0
|
|
509
|
+
|
|
510
|
+
print_to_stderr("\n\nCould not find password. Try a different guess, or increasing max substitutions (-N) ? ")
|
|
457
511
|
return 1
|
|
458
512
|
|
|
459
513
|
|
|
Binary file
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from fiddlesticks import make_py_avdu_aegis_checker, make_pykeepass_checker
|
|
6
|
+
|
|
7
|
+
from .helpers import IS_WINDOWS, KDBX_TEST_VAULT, avdu_test_vault
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.skipif(IS_WINDOWS, reason="I haven't figured out the 7zip CLI on Windows yet")
|
|
11
|
+
def test_is_7zip_installed():
|
|
12
|
+
args = ["7z", "--help"]
|
|
13
|
+
subprocess.check_call(args)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_aegis_checker_against_avdu_vault(avdu_test_vault):
|
|
17
|
+
checker = make_py_avdu_aegis_checker(avdu_test_vault)
|
|
18
|
+
# """
|
|
19
|
+
# # Run using the encrypted test file. (Enter password "test" when prompted.)
|
|
20
|
+
# go run ./cmd/avdu -p test/data/aegis_encrypted.json -e
|
|
21
|
+
# https://github.com/Sammy-T/avdu/blob/master/README.md
|
|
22
|
+
# """
|
|
23
|
+
assert checker("test")
|
|
24
|
+
assert not checker("wrong_password")
|
|
25
|
+
|
|
26
|
+
def test_pykeepass_checker_against_Test_vault():
|
|
27
|
+
checker = make_pykeepass_checker(KDBX_TEST_VAULT)
|
|
28
|
+
assert checker("test")
|
|
29
|
+
assert not checker("wrong_password")
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
import shutil
|
|
3
|
+
import stat
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from hypothesis import HealthCheck, Phase, given, settings
|
|
11
|
+
|
|
12
|
+
import fiddlesticks
|
|
13
|
+
|
|
14
|
+
from .helpers import (
|
|
15
|
+
IS_WINDOWS,
|
|
16
|
+
avdu_test_vault,
|
|
17
|
+
_assert_candidate_within_M_of_pwd,
|
|
18
|
+
_assert_files_same,
|
|
19
|
+
_create_password_protected_7z_archive,
|
|
20
|
+
file_names_and_contents,
|
|
21
|
+
guesses_and_num_subs_from_password,
|
|
22
|
+
passwords_guesses_and_num_subs,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _collate_args(
|
|
27
|
+
num_subs: int,
|
|
28
|
+
guess: str,
|
|
29
|
+
*args: str,
|
|
30
|
+
shell: bool = False,
|
|
31
|
+
) -> list[str]:
|
|
32
|
+
pwd_arg = f"--password-guess={guess}"
|
|
33
|
+
if shell:
|
|
34
|
+
pwd_arg = shlex.quote(pwd_arg)
|
|
35
|
+
return [
|
|
36
|
+
"fiddlesticks",
|
|
37
|
+
"--max-subs", f"{num_subs}",
|
|
38
|
+
pwd_arg,
|
|
39
|
+
*args,
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
def make_internal_checker_args_collater(
|
|
43
|
+
checker: str,
|
|
44
|
+
) -> Callable[[int, Path, str, str], list[str]]:
|
|
45
|
+
def collater(num_subs: int, test_extracted_dir: Path, guess: str, archive: str):
|
|
46
|
+
return _collate_args(
|
|
47
|
+
num_subs,
|
|
48
|
+
guess,
|
|
49
|
+
checker,
|
|
50
|
+
f"--extract-to={test_extracted_dir}",
|
|
51
|
+
archive,
|
|
52
|
+
)
|
|
53
|
+
return collater
|
|
54
|
+
|
|
55
|
+
def shell_collater(num_subs: int, test_extracted_dir: Path, guess: str, archive: str):
|
|
56
|
+
return _collate_args(
|
|
57
|
+
num_subs,
|
|
58
|
+
guess,
|
|
59
|
+
"--shell",
|
|
60
|
+
"--", # Tell argparse all subsequent args are positional
|
|
61
|
+
"7z", # Taken from make_7zip_checker
|
|
62
|
+
"x",
|
|
63
|
+
f"-o{test_extracted_dir}",
|
|
64
|
+
archive,
|
|
65
|
+
"-p",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def pipe_to_bash_while_loop_collater(
|
|
69
|
+
num_subs: int,
|
|
70
|
+
test_extracted_dir: Path,
|
|
71
|
+
guess: str,
|
|
72
|
+
archive: str):
|
|
73
|
+
|
|
74
|
+
script_text = fiddlesticks.PERSISTENT_7Z_CHECKER_OUTLINE.format(
|
|
75
|
+
extract_to=str(test_extracted_dir),
|
|
76
|
+
file=archive,
|
|
77
|
+
)
|
|
78
|
+
script = Path("persistent_checker.sh")
|
|
79
|
+
script.write_text(script_text)
|
|
80
|
+
script.chmod(script.stat().st_mode | stat.S_IXUSR | stat.S_IRUSR)
|
|
81
|
+
|
|
82
|
+
return _collate_args(
|
|
83
|
+
num_subs,
|
|
84
|
+
guess,
|
|
85
|
+
"--pipe",
|
|
86
|
+
"--",
|
|
87
|
+
"|",
|
|
88
|
+
str(script.resolve()),
|
|
89
|
+
shell=True,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@pytest.mark.hypothesis
|
|
93
|
+
@pytest.mark.slow
|
|
94
|
+
@pytest.mark.skipif(IS_WINDOWS, reason="I haven't figured out the 7zip CLI on Windows yet")
|
|
95
|
+
@settings(
|
|
96
|
+
max_examples=5,
|
|
97
|
+
suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large],
|
|
98
|
+
deadline=None,
|
|
99
|
+
database=None,
|
|
100
|
+
derandomize=True, # Without this, the test doesn't complete in less than 5 mins in Github Actions
|
|
101
|
+
# (despite that the default is True in CI ???
|
|
102
|
+
# https://hypothesis.readthedocs.io/en/latest/reference/api.html#hypothesis.settings.derandomize )
|
|
103
|
+
)
|
|
104
|
+
@pytest.mark.parametrize("make_args,shell",[
|
|
105
|
+
(make_internal_checker_args_collater("--7zip"), False),
|
|
106
|
+
(make_internal_checker_args_collater("--7zip-persistent"), False),
|
|
107
|
+
(make_internal_checker_args_collater("--py7zr"), False),
|
|
108
|
+
(shell_collater,False),
|
|
109
|
+
(pipe_to_bash_while_loop_collater,True),
|
|
110
|
+
])
|
|
111
|
+
@given(
|
|
112
|
+
file_names_and_contents=file_names_and_contents,
|
|
113
|
+
password_guess_and_num_subs=passwords_guesses_and_num_subs(max_num_subs=3),
|
|
114
|
+
)
|
|
115
|
+
def test_7z_archives_extracted_via_fiddlesticks_CLI(
|
|
116
|
+
file_names_and_contents: list[tuple[str, bytes]],
|
|
117
|
+
password_guess_and_num_subs: tuple[str,str,int],
|
|
118
|
+
make_args: Callable[[int, Path, str,str], list[str]],
|
|
119
|
+
shell: bool,
|
|
120
|
+
):
|
|
121
|
+
|
|
122
|
+
password, guess, num_subs = password_guess_and_num_subs
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
126
|
+
tmp_path = Path(tmpdir)
|
|
127
|
+
|
|
128
|
+
archive_dir = tmp_path / "contents"
|
|
129
|
+
|
|
130
|
+
_create_password_protected_7z_archive(
|
|
131
|
+
archive_dir,
|
|
132
|
+
file_names_and_contents,
|
|
133
|
+
password,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
archive = str(tmp_path / "archive.7z")
|
|
137
|
+
|
|
138
|
+
result = subprocess.run(
|
|
139
|
+
["7z", "a", "-mhe=on", f"-p{password}", archive, str(archive_dir)],
|
|
140
|
+
capture_output=True,
|
|
141
|
+
check=False,
|
|
142
|
+
)
|
|
143
|
+
assert result.returncode == 0, f"Could not create 7z archive from: {password=}, containing: {file_names_and_contents}"
|
|
144
|
+
|
|
145
|
+
test_extracted_dir = tmp_path / "test_extracted"
|
|
146
|
+
test_extracted_dir.mkdir()
|
|
147
|
+
|
|
148
|
+
# Test if extraction directly with 7z actually works in the test env,
|
|
149
|
+
# before testing if fiddlesticks can do this too.
|
|
150
|
+
result = subprocess.run(
|
|
151
|
+
["7z", "x", f"-p{password}", f"-o{test_extracted_dir}", archive],
|
|
152
|
+
capture_output=True,
|
|
153
|
+
check=False,
|
|
154
|
+
)
|
|
155
|
+
assert result.returncode == 0, f"Could not extract {archive} with known good: {password=}, with 7z directly"
|
|
156
|
+
|
|
157
|
+
_assert_files_same(test_extracted_dir / "contents", file_names_and_contents)
|
|
158
|
+
|
|
159
|
+
shutil.rmtree(test_extracted_dir)
|
|
160
|
+
test_extracted_dir.mkdir()
|
|
161
|
+
|
|
162
|
+
cmd_args = make_args(num_subs, test_extracted_dir, guess, archive)
|
|
163
|
+
if shell:
|
|
164
|
+
cmd_args = " ".join(cmd_args)
|
|
165
|
+
result = subprocess.run(
|
|
166
|
+
cmd_args,
|
|
167
|
+
capture_output=True,
|
|
168
|
+
check=False,
|
|
169
|
+
shell=shell,
|
|
170
|
+
)
|
|
171
|
+
assert result.returncode==0, f"Could not crack: {password} from: {guess=} for {archive}, {num_subs=}"
|
|
172
|
+
|
|
173
|
+
_assert_files_same(test_extracted_dir / "contents", file_names_and_contents)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@pytest.mark.hypothesis
|
|
178
|
+
@pytest.mark.slow
|
|
179
|
+
@pytest.mark.skipif(IS_WINDOWS, reason="I haven't figured out the 7zip CLI on Windows yet")
|
|
180
|
+
@settings(
|
|
181
|
+
max_examples=10,
|
|
182
|
+
phases=[Phase.explicit, Phase.reuse, Phase.generate], # Skip shrinking
|
|
183
|
+
suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large],
|
|
184
|
+
deadline=None,
|
|
185
|
+
# derandomize=True, # Without this, the test doesn't complete in less than 5 mins in Github Actions
|
|
186
|
+
# # (despite that the default is True in CI ???
|
|
187
|
+
# # https://hypothesis.readthedocs.io/en/latest/reference/api.html#hypothesis.settings.derandomize )
|
|
188
|
+
)
|
|
189
|
+
@given(
|
|
190
|
+
password_guess_and_num_subs=passwords_guesses_and_num_subs(max_num_subs=5),
|
|
191
|
+
)
|
|
192
|
+
def test_piping_candidates_to_stdout(
|
|
193
|
+
password_guess_and_num_subs: tuple[str,str,int],
|
|
194
|
+
):
|
|
195
|
+
|
|
196
|
+
password, guess, num_subs = password_guess_and_num_subs
|
|
197
|
+
|
|
198
|
+
result = subprocess.run(
|
|
199
|
+
_collate_args(num_subs, guess, "--pipe"),
|
|
200
|
+
capture_output=True,
|
|
201
|
+
check=False,
|
|
202
|
+
)
|
|
203
|
+
assert result.returncode==0, f"Could not --pipe candidate passwords, {num_subs=}, {guess=}"
|
|
204
|
+
|
|
205
|
+
candidates = set()
|
|
206
|
+
for candidate in result.stdout.decode().splitlines():
|
|
207
|
+
_assert_candidate_within_M_of_pwd(candidate, guess, M=num_subs)
|
|
208
|
+
candidates.add(candidate)
|
|
209
|
+
|
|
210
|
+
assert password in candidates, f"Did not find {password=} from {guess=}, {num_subs=}, {candidates=}"
|
|
211
|
+
|
|
212
|
+
# """
|
|
213
|
+
# # Run using the encrypted test file. (Enter password "test" when prompted.)
|
|
214
|
+
# go run ./cmd/avdu -p test/data/aegis_encrypted.json -e
|
|
215
|
+
# https://github.com/Sammy-T/avdu/blob/master/README.md
|
|
216
|
+
# """
|
|
217
|
+
@pytest.mark.hypothesis
|
|
218
|
+
@pytest.mark.slow
|
|
219
|
+
@given(guess_and_num_subs=guesses_and_num_subs_from_password("test", max_num_subs=4))
|
|
220
|
+
@settings(
|
|
221
|
+
max_examples=10,
|
|
222
|
+
phases=[Phase.explicit, Phase.reuse, Phase.generate], # Skip shrinking
|
|
223
|
+
suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large],
|
|
224
|
+
deadline=None,
|
|
225
|
+
# derandomize=True, # Without this, the test doesn't complete in less than 5 mins in Github Actions
|
|
226
|
+
# # (despite that the default is True in CI ???
|
|
227
|
+
# # https://hypothesis.readthedocs.io/en/latest/reference/api.html#hypothesis.settings.derandomize )
|
|
228
|
+
)
|
|
229
|
+
def test_aegis_checker_from_CLI(guess_and_num_subs, avdu_test_vault):
|
|
230
|
+
guess, num_subs = guess_and_num_subs
|
|
231
|
+
result = subprocess.run(
|
|
232
|
+
_collate_args(num_subs, guess, "--aegis", str(avdu_test_vault)),
|
|
233
|
+
capture_output=True,
|
|
234
|
+
check=False,
|
|
235
|
+
)
|
|
236
|
+
assert result.returncode==0, f"Using Aegis encrypted vault checker, could not find 'test' from {guess=}, {num_subs=}"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_aegis_checker_errors_from_CLI(avdu_test_vault):
|
|
240
|
+
num_subs = 4
|
|
241
|
+
guess = "abcd", # i.e. not "test" (but not too long either)
|
|
242
|
+
result = subprocess.run(
|
|
243
|
+
_collate_args(
|
|
244
|
+
num_subs,
|
|
245
|
+
guess,
|
|
246
|
+
"--aegis",
|
|
247
|
+
str(avdu_test_vault),
|
|
248
|
+
),
|
|
249
|
+
capture_output=True,
|
|
250
|
+
check=False,
|
|
251
|
+
)
|
|
252
|
+
assert result.returncode != 0, f"Failed to error on bad {guess=}, or unexpectedly found password ('test') of Aegis encrypted vault checker, {num_subs=}"
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
|
|
2
|
+
import string
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from hypothesis.strategies import (
|
|
9
|
+
binary,
|
|
10
|
+
composite, # Can be slow
|
|
11
|
+
integers,
|
|
12
|
+
lists,
|
|
13
|
+
sampled_from,
|
|
14
|
+
text,
|
|
15
|
+
tuples,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
import fiddlesticks
|
|
19
|
+
|
|
20
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
21
|
+
BI_MAP = fiddlesticks.SHIFT_AND_LEET_BI_MAP
|
|
22
|
+
KDBX_TEST_VAULT = Path(__file__).parent / "Test_vault_Do_Not_Use.kdbx"
|
|
23
|
+
|
|
24
|
+
file_names = text(
|
|
25
|
+
alphabet=string.ascii_letters + string.digits,
|
|
26
|
+
# characters(
|
|
27
|
+
# codec="ascii",
|
|
28
|
+
# exclude_categories=["Cs", "Co", "Cn"],
|
|
29
|
+
# ),
|
|
30
|
+
min_size=1,
|
|
31
|
+
max_size=24,
|
|
32
|
+
)
|
|
33
|
+
file_names_and_contents = lists(
|
|
34
|
+
tuples(file_names, binary(min_size=1, max_size=1024)),
|
|
35
|
+
min_size=1,
|
|
36
|
+
max_size=20,
|
|
37
|
+
unique_by=lambda x: x[0],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
password_chars = set(string.ascii_letters + string.digits + string.punctuation)
|
|
41
|
+
|
|
42
|
+
# We use single quotes to pass the PW guess as a Bash literal string.
|
|
43
|
+
# Otherwise much more complicated escaping rules are required.
|
|
44
|
+
password_chars -= {"'"}
|
|
45
|
+
|
|
46
|
+
passwords = text(
|
|
47
|
+
alphabet="".join(password_chars),
|
|
48
|
+
min_size=1,
|
|
49
|
+
max_size=40,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _create_password_protected_7z_archive(
|
|
54
|
+
dir_: Path,
|
|
55
|
+
file_names_and_contents: list[tuple[str, bytes]],
|
|
56
|
+
password: str,
|
|
57
|
+
):
|
|
58
|
+
dir_.mkdir(exist_ok=True, parents=True)
|
|
59
|
+
for file_name, content in file_names_and_contents:
|
|
60
|
+
(dir_ / file_name).write_bytes(content)
|
|
61
|
+
|
|
62
|
+
def _assert_files_same(
|
|
63
|
+
dir_: Path,
|
|
64
|
+
file_names_and_contents: list[tuple[str, bytes]],
|
|
65
|
+
):
|
|
66
|
+
for file_name, content in file_names_and_contents:
|
|
67
|
+
actual = (dir_ / file_name).read_bytes()
|
|
68
|
+
assert actual == content, f"File did not round trip. Expected: {content=}. Got: {actual=}"
|
|
69
|
+
|
|
70
|
+
@composite
|
|
71
|
+
def _alts_and_num_subs_from_password(
|
|
72
|
+
draw,
|
|
73
|
+
password: str,
|
|
74
|
+
max_num_subs: int = 3,
|
|
75
|
+
):
|
|
76
|
+
|
|
77
|
+
all_alts = []
|
|
78
|
+
for i, c in enumerate(password):
|
|
79
|
+
alts = set(BI_MAP.get(c))
|
|
80
|
+
# To allow passing of PW guesses in single quotes via Bash,
|
|
81
|
+
alts -= {"'"}
|
|
82
|
+
if alts:
|
|
83
|
+
all_alts.append((i, "".join(alts)))
|
|
84
|
+
|
|
85
|
+
num_subs = draw(integers(min_value=0, max_value=min(max_num_subs, len(all_alts))))
|
|
86
|
+
|
|
87
|
+
selected_alts = draw(
|
|
88
|
+
lists(
|
|
89
|
+
sampled_from(all_alts),
|
|
90
|
+
min_size=num_subs,
|
|
91
|
+
max_size=num_subs,
|
|
92
|
+
unique=True,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
return selected_alts, num_subs
|
|
96
|
+
|
|
97
|
+
@composite
|
|
98
|
+
def selected_alts_and_num_subs_from_password(
|
|
99
|
+
draw,
|
|
100
|
+
password: str,
|
|
101
|
+
max_num_subs: int = 3,
|
|
102
|
+
):
|
|
103
|
+
selected_alts, num_subs = draw(_alts_and_num_subs_from_password(password, max_num_subs))
|
|
104
|
+
return selected_alts, num_subs
|
|
105
|
+
|
|
106
|
+
@composite
|
|
107
|
+
def guesses_from_alts(
|
|
108
|
+
draw,
|
|
109
|
+
password: str,
|
|
110
|
+
selected_alts: list[tuple[int,str]],
|
|
111
|
+
):
|
|
112
|
+
|
|
113
|
+
chars = list(password)
|
|
114
|
+
for i, alts in selected_alts:
|
|
115
|
+
chars[i] = draw(sampled_from(alts))
|
|
116
|
+
return "".join(chars)
|
|
117
|
+
|
|
118
|
+
@composite
|
|
119
|
+
def guesses_and_num_subs_from_password(
|
|
120
|
+
draw,
|
|
121
|
+
password: str,
|
|
122
|
+
max_num_subs: int = 3,
|
|
123
|
+
):
|
|
124
|
+
selected_alts, num_subs = draw(selected_alts_and_num_subs_from_password(password, max_num_subs))
|
|
125
|
+
guess = draw(guesses_from_alts(password, selected_alts))
|
|
126
|
+
return guess, num_subs
|
|
127
|
+
|
|
128
|
+
@composite
|
|
129
|
+
def passwords_guesses_and_num_subs(
|
|
130
|
+
draw,
|
|
131
|
+
max_num_subs: int = 3,
|
|
132
|
+
):
|
|
133
|
+
|
|
134
|
+
password = draw(passwords)
|
|
135
|
+
guess, num_subs = draw(guesses_and_num_subs_from_password(password, max_num_subs))
|
|
136
|
+
return password, guess, num_subs
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _assert_candidate_within_M_of_pwd(candidate: str, pwd: str, M: int) -> None:
|
|
142
|
+
# Current implementation preserves length
|
|
143
|
+
assert len(pwd) == len(candidate)
|
|
144
|
+
i = 0
|
|
145
|
+
for c1, c2 in zip(pwd, candidate):
|
|
146
|
+
if c1 != c2:
|
|
147
|
+
i += 1
|
|
148
|
+
assert c2 in BI_MAP[c1], f"Unrelated: {c1}, {c2}"
|
|
149
|
+
assert i <= M
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@pytest.fixture(scope="session")
|
|
153
|
+
def avdu_test_vault(tmp_path_factory):
|
|
154
|
+
avdu_repo = tmp_path_factory.mktemp("avdu")
|
|
155
|
+
subprocess.run(
|
|
156
|
+
["git",
|
|
157
|
+
"clone",
|
|
158
|
+
"--depth=1",
|
|
159
|
+
"https://github.com/Sammy-T/avdu/",
|
|
160
|
+
str(avdu_repo),
|
|
161
|
+
],
|
|
162
|
+
check=True,
|
|
163
|
+
capture_output=True,
|
|
164
|
+
)
|
|
165
|
+
return avdu_repo / "test" / "data" / "aegis_encrypted.json"
|
|
166
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
|
|
2
|
+
import pytest
|
|
3
|
+
from hypothesis import HealthCheck, given, settings
|
|
4
|
+
|
|
5
|
+
import fiddlesticks
|
|
6
|
+
|
|
7
|
+
from .helpers import (
|
|
8
|
+
_assert_candidate_within_M_of_pwd,
|
|
9
|
+
passwords_guesses_and_num_subs,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.hypothesis
|
|
14
|
+
@pytest.mark.slow
|
|
15
|
+
@given(password_guess_and_num_subs=passwords_guesses_and_num_subs(max_num_subs=10))
|
|
16
|
+
@settings(
|
|
17
|
+
deadline=None,
|
|
18
|
+
suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large],
|
|
19
|
+
)
|
|
20
|
+
def test_alt_chars_candidates_generator(password_guess_and_num_subs: tuple[str,list[tuple[int,str]],int]):
|
|
21
|
+
pwd, _guesses, M = password_guess_and_num_subs
|
|
22
|
+
_total, candidates = fiddlesticks.candidate_passwords_from_alt_chars(pwd, M)
|
|
23
|
+
for candidate in candidates:
|
|
24
|
+
_assert_candidate_within_M_of_pwd(candidate, pwd, M)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
* text eol=lf
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
# This .gitignore file was automatically created by Hypothesis. Hypothesis gitignores
|
|
2
|
-
# .hypothesis by default, because we generally recommend that .hypothesis not be checked
|
|
3
|
-
# into version control.
|
|
4
|
-
#
|
|
5
|
-
# If you *would* like to check .hypothesis into version control, you should delete this
|
|
6
|
-
# file. Hypothesis will not re-create this .gitignore unless .hypothesis is deleted (and
|
|
7
|
-
# if it does, that's a bug - please report it!)
|
|
8
|
-
|
|
9
|
-
*
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
# file: C:\Users\drjam\Coding\repos\fiddlesticks\src\fiddlesticks.py
|
|
2
|
-
# hypothesis_version: 6.165.10
|
|
3
|
-
|
|
4
|
-
[300, 1000, '!', '#', '#£', '$', '%', '&', "'", '(', ')', '*', '+', ',', '-', '--7zip', '--7zip-persistent', '--aegis-pyavdu', '--extract-to', '--help', '--max-subs', '--output-file', '--password-guess', '--pipe', '--print-passwords', '--py7zr', '--shell', '--shift_and_leet', '--verbosity', '-N', '-P', '-c', '-o', '-p', '-v', '-x', '.', '.7z', '/', '0', '0.0.0', '1', '2', '3', '4', '4@', '5', '6', '7', '7z', '8', '9', ':', ';', '<', '=', '>', '?', '@', '@"', 'Character map', 'Password checker', 'Success', '[', '\\', ']', '^', '_', '__main__', '`', 'a', 'alt_char_map', 'at', 'b', 'bash', 'checker_factory', 'count', 'e', 'extend', 'extras', 'fiddlesticks', 'g', 'i', 'o', 'output_file', 'r', 's', 'store_const', 'store_true', 't', 'x', '{', '|', '}', '~']
|
|
Binary file
|
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
|
-
import random
|
|
3
|
-
import shutil
|
|
4
|
-
import string
|
|
5
|
-
import subprocess
|
|
6
|
-
import sys
|
|
7
|
-
import tempfile
|
|
8
|
-
|
|
9
|
-
import pytest
|
|
10
|
-
from hypothesis import given, settings, HealthCheck, Phase
|
|
11
|
-
from hypothesis.strategies import (
|
|
12
|
-
lists,
|
|
13
|
-
tuples,
|
|
14
|
-
text,
|
|
15
|
-
characters,
|
|
16
|
-
binary,
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
import fiddlesticks
|
|
20
|
-
|
|
21
|
-
IS_WINDOWS = sys.platform=="win32"
|
|
22
|
-
|
|
23
|
-
@pytest.mark.skipif(IS_WINDOWS, reason="I haven't figured out the 7zip CLI on Windows yet")
|
|
24
|
-
def test_is_7zip_installed():
|
|
25
|
-
args = ["7z", "--help"]
|
|
26
|
-
result = subprocess.run(args, capture_output=True)
|
|
27
|
-
assert result.returncode == 0
|
|
28
|
-
|
|
29
|
-
file_names = text(
|
|
30
|
-
alphabet=string.ascii_letters + string.digits,
|
|
31
|
-
# characters(
|
|
32
|
-
# codec="ascii",
|
|
33
|
-
# exclude_categories=["Cs", "Co", "Cn"],
|
|
34
|
-
# ),
|
|
35
|
-
min_size=1,
|
|
36
|
-
max_size=24,
|
|
37
|
-
)
|
|
38
|
-
file_names_and_contents = lists(
|
|
39
|
-
tuples(file_names, binary(min_size=1, max_size=1024)),
|
|
40
|
-
min_size=1,
|
|
41
|
-
max_size=20,
|
|
42
|
-
unique_by=lambda x: x[0],
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
password_chars = set(string.ascii_letters + string.digits + string.punctuation)
|
|
46
|
-
#To void password chars being parsed as control chars by Bash
|
|
47
|
-
# password_chars -= set("\\"+r"'&;*:{$")
|
|
48
|
-
|
|
49
|
-
passwords = text(
|
|
50
|
-
alphabet="".join(password_chars),
|
|
51
|
-
min_size=1,
|
|
52
|
-
max_size=40,
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
BI_MAP = fiddlesticks.SHIFT_AND_LEET_BI_MAP
|
|
56
|
-
|
|
57
|
-
# Don't include ' being interpreted as a Bash control char
|
|
58
|
-
# in the tests
|
|
59
|
-
del BI_MAP["'"]
|
|
60
|
-
for k in list(BI_MAP):
|
|
61
|
-
if "'" in BI_MAP[k]:
|
|
62
|
-
del BI_MAP[k]
|
|
63
|
-
|
|
64
|
-
def _get_num_subs(pwd: str) -> int:
|
|
65
|
-
L = len(pwd)
|
|
66
|
-
return min(L, 4)
|
|
67
|
-
|
|
68
|
-
@pytest.mark.hypothesis
|
|
69
|
-
@pytest.mark.slow
|
|
70
|
-
@given(pwd=passwords)
|
|
71
|
-
@settings(deadline=None, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large])
|
|
72
|
-
def test_alt_chars_candidates_generator(pwd: str):
|
|
73
|
-
M = _get_num_subs(pwd)
|
|
74
|
-
total, candidates = fiddlesticks.candidate_passwords_from_alt_chars(pwd, M)
|
|
75
|
-
for candidate in candidates:
|
|
76
|
-
# Current implementation preserves length
|
|
77
|
-
assert len(pwd) == len(candidate)
|
|
78
|
-
i = 0
|
|
79
|
-
for c1, c2 in zip(pwd, candidate):
|
|
80
|
-
if c1 != c2:
|
|
81
|
-
i += 1
|
|
82
|
-
assert c1 in BI_MAP[c2] or c2 in BI_MAP[c1], f"Unrelated: {c1}, {c2}"
|
|
83
|
-
assert i <= M
|
|
84
|
-
|
|
85
|
-
def _create_password_protected_7z_archive(
|
|
86
|
-
dir_: Path,
|
|
87
|
-
file_names_and_contents: list[tuple[str, bytes]],
|
|
88
|
-
password: str,
|
|
89
|
-
):
|
|
90
|
-
dir_.mkdir(exist_ok=True, parents=True)
|
|
91
|
-
for file_name, content in file_names_and_contents:
|
|
92
|
-
(dir_ / file_name).write_bytes(content)
|
|
93
|
-
|
|
94
|
-
def _assert_files_same(
|
|
95
|
-
dir_: Path,
|
|
96
|
-
file_names_and_contents: list[tuple[str, bytes]],
|
|
97
|
-
):
|
|
98
|
-
for file_name, content in file_names_and_contents:
|
|
99
|
-
actual = (dir_ / file_name).read_bytes()
|
|
100
|
-
assert actual == content, f"File did not round trip. Expected: {content=}. Got: {actual=}"
|
|
101
|
-
|
|
102
|
-
def _guess_from_password(
|
|
103
|
-
password: str,
|
|
104
|
-
max_subs: int,
|
|
105
|
-
) -> str:
|
|
106
|
-
indices_and_alt_chars_with_alts = []
|
|
107
|
-
for i, c in enumerate(password):
|
|
108
|
-
alts = BI_MAP.get(c)
|
|
109
|
-
if alts:
|
|
110
|
-
indices_and_alt_chars_with_alts.append((i, alts))
|
|
111
|
-
|
|
112
|
-
chars = list(password)
|
|
113
|
-
max_subs = min(max_subs, len(indices_and_alt_chars_with_alts))
|
|
114
|
-
|
|
115
|
-
for i, alts in random.sample(indices_and_alt_chars_with_alts, max_subs):
|
|
116
|
-
chars[i] = random.choice(alts)
|
|
117
|
-
return "".join(chars)
|
|
118
|
-
|
|
119
|
-
@pytest.mark.hypothesis
|
|
120
|
-
@pytest.mark.slow
|
|
121
|
-
@pytest.mark.skipif(IS_WINDOWS, reason="I haven't figured out the 7zip CLI on Windows yet")
|
|
122
|
-
@settings(
|
|
123
|
-
max_examples=50,
|
|
124
|
-
phases=[Phase.explicit, Phase.reuse, Phase.generate], # Skip shrinking
|
|
125
|
-
suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large],
|
|
126
|
-
deadline=None,
|
|
127
|
-
)
|
|
128
|
-
@given(
|
|
129
|
-
file_names_and_contents=file_names_and_contents,
|
|
130
|
-
password=passwords,
|
|
131
|
-
)
|
|
132
|
-
def test_7zip_checker(
|
|
133
|
-
file_names_and_contents: list[tuple[str, bytes]],
|
|
134
|
-
password: str,
|
|
135
|
-
):
|
|
136
|
-
with tempfile.TemporaryDirectory(delete=False) as tmpdir:
|
|
137
|
-
tmp_path = Path(tmpdir)
|
|
138
|
-
|
|
139
|
-
archive_dir = tmp_path / "contents"
|
|
140
|
-
|
|
141
|
-
_create_password_protected_7z_archive(
|
|
142
|
-
archive_dir,
|
|
143
|
-
file_names_and_contents,
|
|
144
|
-
password,
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
archive = str(tmp_path / "archive.7z")
|
|
148
|
-
|
|
149
|
-
result = subprocess.run(
|
|
150
|
-
["7z", "a", "-mhe=on", f"-p{password}", archive, str(archive_dir)],
|
|
151
|
-
capture_output=True,
|
|
152
|
-
)
|
|
153
|
-
assert result.returncode == 0, f"Could not create 7z archive from: {password=}, containing: {file_names_and_contents}"
|
|
154
|
-
|
|
155
|
-
test_extracted_dir = tmp_path / "test_extracted"
|
|
156
|
-
test_extracted_dir.mkdir()
|
|
157
|
-
|
|
158
|
-
result = subprocess.run(
|
|
159
|
-
["7z", "x", f"-p{password}", f"-o{test_extracted_dir}", archive],
|
|
160
|
-
capture_output=True,
|
|
161
|
-
)
|
|
162
|
-
assert result.returncode == 0, f"Could not extract {archive} with known good: {password=}, with 7z directly"
|
|
163
|
-
|
|
164
|
-
_assert_files_same(test_extracted_dir / "contents", file_names_and_contents)
|
|
165
|
-
|
|
166
|
-
shutil.rmtree(test_extracted_dir)
|
|
167
|
-
test_extracted_dir.mkdir()
|
|
168
|
-
|
|
169
|
-
max_subs = _get_num_subs(password)
|
|
170
|
-
guess = _guess_from_password(password, max_subs)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
result = subprocess.run(
|
|
174
|
-
["fiddlesticks","--7zip",
|
|
175
|
-
"--max-subs", f"{max_subs}",
|
|
176
|
-
f"--extract-to={test_extracted_dir}",
|
|
177
|
-
f"--password-guess={guess}", archive
|
|
178
|
-
],
|
|
179
|
-
capture_output=True,
|
|
180
|
-
)
|
|
181
|
-
assert result.returncode==0, f"Could not crack: {password} from: {guess=} for {archive}, {max_subs=}"
|
|
182
|
-
|
|
183
|
-
_assert_files_same(test_extracted_dir / "contents", file_names_and_contents)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
File without changes
|
|
File without changes
|