fiddlesticks 0.3.0__tar.gz → 0.4.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.
Files changed (25) hide show
  1. fiddlesticks-0.4.0/.gitattributes +5 -0
  2. fiddlesticks-0.4.0/.github/workflows/tests.yml +44 -0
  3. {fiddlesticks-0.3.0 → fiddlesticks-0.4.0}/PKG-INFO +12 -5
  4. {fiddlesticks-0.3.0 → fiddlesticks-0.4.0}/README.md +6 -4
  5. {fiddlesticks-0.3.0 → fiddlesticks-0.4.0}/pyproject.toml +12 -5
  6. {fiddlesticks-0.3.0 → fiddlesticks-0.4.0}/src/fiddlesticks.py +231 -30
  7. fiddlesticks-0.4.0/tests/checker_tests.py +144 -0
  8. fiddlesticks-0.4.0/tests/data_files/Test_vault_Do_Not_Use.kdbx +0 -0
  9. fiddlesticks-0.4.0/tests/data_files/test.docx +0 -0
  10. fiddlesticks-0.4.0/tests/data_files/test.xlsx +0 -0
  11. {fiddlesticks-0.3.0 → fiddlesticks-0.4.0}/tests/end_to_end_tests.py +16 -2
  12. {fiddlesticks-0.3.0 → fiddlesticks-0.4.0}/tests/helpers.py +84 -3
  13. fiddlesticks-0.4.0/tests/misc_tests.py +121 -0
  14. {fiddlesticks-0.3.0 → fiddlesticks-0.4.0}/tests/pw_generator_tests.py +0 -11
  15. fiddlesticks-0.3.0/.gitattributes +0 -2
  16. fiddlesticks-0.3.0/.github/workflows/tests.yml +0 -27
  17. fiddlesticks-0.3.0/tests/Test_vault_Do_Not_Use.kdbx +0 -0
  18. fiddlesticks-0.3.0/tests/checker_tests.py +0 -75
  19. {fiddlesticks-0.3.0 → fiddlesticks-0.4.0}/.github/workflows/lint.yml +0 -0
  20. {fiddlesticks-0.3.0 → fiddlesticks-0.4.0}/.gitignore +0 -0
  21. {fiddlesticks-0.3.0 → fiddlesticks-0.4.0}/.pre-commit-config.yaml +0 -0
  22. {fiddlesticks-0.3.0 → fiddlesticks-0.4.0}/CANARY +0 -0
  23. {fiddlesticks-0.3.0 → fiddlesticks-0.4.0}/LICENSE +0 -0
  24. {fiddlesticks-0.3.0 → fiddlesticks-0.4.0}/tests/__init__.py +0 -0
  25. {fiddlesticks-0.3.0/tests → fiddlesticks-0.4.0/tests/data_files}/foo.7z +0 -0
@@ -0,0 +1,5 @@
1
+ * text eol=lf
2
+ *.kdbx binary
3
+ *.7z binary
4
+ *.docx binary
5
+ *.xlsx binary
@@ -0,0 +1,44 @@
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
+
12
+ steps:
13
+ - name: Check out repo
14
+ uses: actions/checkout@v7
15
+
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v7
18
+ with:
19
+ python-version: "3.14"
20
+
21
+
22
+ - name: Check Git hasn't corrupted test data files
23
+ run: |
24
+ sha256sum tests/data_files/Test_vault_Do_Not_Use.kdbx
25
+
26
+ - name: Install Veracrypt from unit193's apt repo
27
+ run: |
28
+ sudo add-apt-repository ppa:unit193/encryption
29
+ sudo apt update
30
+ sudo apt install veracrypt
31
+
32
+ - name: Create venv
33
+ run: |
34
+ python3 -m venv .venv
35
+
36
+ - name: Install dependencies
37
+ run: |
38
+ . ./.venv/bin/activate
39
+ pip install -e . --group=test
40
+
41
+ - name: Run PyTest (via Coverage)
42
+ run: |
43
+ . ./.venv/bin/activate
44
+ coverage run -m pytest -rA -vv --tb=short tests && coverage combine && coverage report
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fiddlesticks
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Password recovery tool for encrypted files (.7z archives .kdbx files, and Aegis archives).
5
5
  Project-URL: GitHub, https://github.com/Hazardous-Area/fiddlesticks
6
6
  Author-email: James Parrott <james@jamesparrott.dev>
@@ -11,8 +11,13 @@ Provides-Extra: aegis
11
11
  Requires-Dist: py-avdu; extra == 'aegis'
12
12
  Provides-Extra: keepassxc
13
13
  Requires-Dist: pykeepass; extra == 'keepassxc'
14
+ Provides-Extra: msoffice
15
+ Requires-Dist: msoffcrypto-tool; extra == 'msoffice'
14
16
  Provides-Extra: py7zr
15
17
  Requires-Dist: py7zr; extra == 'py7zr'
18
+ Provides-Extra: ssh
19
+ Requires-Dist: bcrypt; extra == 'ssh'
20
+ Requires-Dist: cryptography>=47; extra == 'ssh'
16
21
  Description-Content-Type: text/markdown
17
22
 
18
23
  # Fiddlesticks!
@@ -20,14 +25,16 @@ Description-Content-Type: text/markdown
20
25
  ![Tests passing](https://github.com/Hazardous-Area/fiddlesticks/actions/workflows/tests.yml/badge.svg)
21
26
  ![Code qual](https://github.com/Hazardous-Area/fiddlesticks/actions/workflows/lint.yml/badge.svg)
22
27
 
23
- Version 0.3.0
28
+ Version 0.4.0
24
29
 
25
30
  ## Description
26
31
  Password recovery tool, for password-encrypted files, using simple off-line brute
27
32
  force attacks. Password candidates are generated, using common variations
28
- of a guessed password (e.g. typos and substitutions). .7z, .kdbx and Aegis archives
29
- are directly supported, but Fiddlesticks can also call any shell command, that accepts
30
- a candidate password.
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).
31
38
 
32
39
  ### Raison d'etre
33
40
  - Password-protected file owners recovering their own password themselves, as long as
@@ -3,14 +3,16 @@
3
3
  ![Tests passing](https://github.com/Hazardous-Area/fiddlesticks/actions/workflows/tests.yml/badge.svg)
4
4
  ![Code qual](https://github.com/Hazardous-Area/fiddlesticks/actions/workflows/lint.yml/badge.svg)
5
5
 
6
- Version 0.3.0
6
+ Version 0.4.0
7
7
 
8
8
  ## Description
9
9
  Password recovery tool, for password-encrypted files, using simple off-line brute
10
10
  force attacks. Password candidates are generated, using common variations
11
- of a guessed password (e.g. typos and substitutions). .7z, .kdbx and Aegis archives
12
- are directly supported, but Fiddlesticks can also call any shell command, that accepts
13
- a candidate password.
11
+ of a guessed password (e.g. typos and substitutions). SSH keys, Aegis archives,
12
+ and Veracrypt volumes, plus .7z, .kdbx, .xlsx, and .docx files,
13
+ are directly supported as optional dependencies. But
14
+ Fiddlesticks can also call any shell command, that accepts a candidate password,
15
+ e.g. for Veracrypt volumes (and can pipe candidates to stdout).
14
16
 
15
17
  ### Raison d'etre
16
18
  - Password-protected file owners recovering their own password themselves, as long as
@@ -23,14 +23,21 @@ GitHub = "https://github.com/Hazardous-Area/fiddlesticks"
23
23
  fiddlesticks = "fiddlesticks:cli"
24
24
 
25
25
  [project.optional-dependencies]
26
- py7zr = [
27
- "py7zr",
26
+ ssh = [
27
+ "cryptography>=47", # For "incorrect PW" error message from Rust code
28
+ "bcrypt",
29
+ ]
30
+ keepassxc = [
31
+ "pykeepass",
28
32
  ]
29
33
  aegis = [
30
34
  "py-avdu",
31
35
  ]
32
- keepassxc = [
33
- "pykeepass",
36
+ py7zr = [
37
+ "py7zr",
38
+ ]
39
+ msoffice = [
40
+ "msoffcrypto-tool",
34
41
  ]
35
42
 
36
43
  [dependency-groups]
@@ -39,7 +46,7 @@ dev = [
39
46
  {include-group = "test"},
40
47
  ]
41
48
  test = [
42
- "fiddlesticks[py7zr,aegis,keepassxc]",
49
+ "fiddlesticks[ssh,keepassxc,aegis,py7zr,msoffice]",
43
50
  "pytest",
44
51
  "hypothesis",
45
52
  "coverage>=7.13",
@@ -5,13 +5,21 @@
5
5
 
6
6
  # # Non-compliant with PEP 723:
7
7
  # optional_dependencies = [
8
- # py7zr = ["py7zr"],
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
9
11
  # aegis = ["py-avdu"],
10
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"],
11
19
  # ]
12
20
  # ///
13
21
 
14
- __version__ = "0.3.0"
22
+ __version__ = "0.4.0"
15
23
 
16
24
  import argparse
17
25
  import atexit
@@ -80,11 +88,11 @@ for c, v in list(LEET_SPEAK.items()):
80
88
  LEET_SPEAK[c.upper()] = v
81
89
 
82
90
 
83
- def print_to_stderr(*objects, file=sys.stderr, **kwargs):
91
+ def print_to_stderr(*objects, **kwargs):
84
92
  """Simple wrapper to print to stderr instead of stdout,
85
- for easy piping to stdout.
93
+ for easy piping (of other output) to stdout.
86
94
  """
87
- print(*objects, file=file, **kwargs)
95
+ print(*objects, file=sys.stderr, **kwargs)
88
96
 
89
97
 
90
98
  def _calculate_total(lengths, M):
@@ -189,6 +197,26 @@ def candidate_passwords_from_alt_chars(
189
197
  return total_num_candidates, _candidates_from_alts_dict(guesses_alts, max_subs)
190
198
 
191
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
+
192
220
  def make_py7zr_checker(archive: str, extract_to: str | None = None, **kwargs):
193
221
  from _lzma import LZMAError
194
222
 
@@ -196,7 +224,7 @@ def make_py7zr_checker(archive: str, extract_to: str | None = None, **kwargs):
196
224
  from py7zr.exceptions import Bad7zFile, PasswordRequired
197
225
 
198
226
  if extract_to is None:
199
- extract_to = str(_make_new_tmp_sub_dir(archive))
227
+ extract_to = str(_make_new_tmp_sub_dir_for_7z(archive))
200
228
  stream = io.BytesIO(Path(archive).read_bytes())
201
229
 
202
230
  def is_correct_password_for_7z_file(candidate: str) -> bool:
@@ -221,10 +249,10 @@ def make_subprocess_checker(*args: str, **kwargs):
221
249
  # On the other hand, if last == "-p", e.g. with 7z,
222
250
  # the password is expected with no space separating it
223
251
  # from the -p.
224
- # Therefore to stick with out contract of "any partial
252
+ # Therefore to honour our contract of "any partial
225
253
  # Bash command to which a password guess can be appended"
226
- # it's easiest to use a single string,
227
- # and (unless on Windows) shell=True
254
+ # it's easiest to use a single string (instead of an args list),
255
+ # and (unless on Windows) shell=True.
228
256
 
229
257
  def checker(candidate: str) -> bool:
230
258
  result = subprocess.run(
@@ -238,13 +266,18 @@ def make_subprocess_checker(*args: str, **kwargs):
238
266
  return checker
239
267
 
240
268
 
241
- def _make_new_tmp_sub_dir(file: str, tmp_dir: Path = TMP_DIR) -> Path:
269
+ def _make_new_tmp_sub_dir(tmp_dir, name: str = "extracted") -> Path:
242
270
  i = -1
243
271
  suffix = ""
244
- while (p := tmp_dir / f"extracted{suffix}").is_dir():
272
+ while (p := tmp_dir / f"{name}{suffix}").is_dir():
245
273
  i += 1
246
274
  suffix = f"_{i}"
247
- p.mkdir()
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)
248
281
  print_to_stderr(f"If {file} is unzipped successfully, contents will be in: {p}")
249
282
  return p
250
283
 
@@ -255,7 +288,7 @@ def make_7zip_checker(file: str, extract_to: str | None = None, **kwargs):
255
288
  subprocess.run(["7z", "--help"], capture_output=True, check=True)
256
289
 
257
290
  if extract_to is None:
258
- extract_to = str(_make_new_tmp_sub_dir(file))
291
+ extract_to = str(_make_new_tmp_sub_dir_for_7z(file))
259
292
 
260
293
  return make_subprocess_checker("7z", "x", f"-o{extract_to}", file, "-p")
261
294
 
@@ -285,7 +318,7 @@ done
285
318
 
286
319
  def make_persistent_7zip_checker(file: str, extract_to: str | None = None, **kwargs):
287
320
  if extract_to is None:
288
- extract_to = str(_make_new_tmp_sub_dir(file))
321
+ extract_to = str(_make_new_tmp_sub_dir_for_7z(file))
289
322
 
290
323
  cmd = textwrap.dedent(
291
324
  PERSISTENT_7Z_CHECKER_OUTLINE.format(extract_to=extract_to, file=file)
@@ -353,6 +386,162 @@ def make_pykeepass_checker(file: os.PathLike, **kwargs):
353
386
  return checker
354
387
 
355
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
+
356
545
  def check_passwords_sequentially(
357
546
  candidates: Iterable[tuple[str, int]],
358
547
  test_func: Callable[[str], bool],
@@ -405,6 +594,13 @@ default_password_protected_file_checker_factories = {
405
594
  ".json": make_py_avdu_aegis_checker,
406
595
  ".kdbx": make_pykeepass_checker,
407
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,
408
604
  }
409
605
 
410
606
 
@@ -543,12 +739,9 @@ def add_command_arg(name, command, help: str | None = None):
543
739
  )
544
740
 
545
741
 
742
+ add_command_arg("--shell", make_subprocess_checker)
546
743
  add_command_arg("--7zip", make_7zip_checker)
547
744
  add_command_arg("--7zip-persistent", make_persistent_7zip_checker)
548
- add_command_arg("--shell", make_subprocess_checker)
549
- add_command_arg("--py7zr", make_py7zr_checker)
550
- add_command_arg("--aegis", make_py_avdu_aegis_checker)
551
- add_command_arg("--keypassxc", make_pykeepass_checker)
552
745
  add_command_arg(
553
746
  "--pipe",
554
747
  make_password_candidate_piper,
@@ -559,6 +752,15 @@ add_command_arg(
559
752
  ),
560
753
  )
561
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)
562
764
 
563
765
 
564
766
  alt_char_map_group = add_mutex_group(
@@ -635,9 +837,7 @@ def cli(args: list[str] = sys.argv[1:]) -> int:
635
837
  ):
636
838
  password_guesses.append(password_guess)
637
839
 
638
- output_file = kwargs.pop("output_file")
639
840
  extras = kwargs.pop("extras")
640
- extract_to = kwargs.pop("extract_to")
641
841
 
642
842
  if ns.command is None:
643
843
  command = _default_factory_selector(*extras)
@@ -645,13 +845,19 @@ def cli(args: list[str] = sys.argv[1:]) -> int:
645
845
  command = ns.command
646
846
 
647
847
  if (
648
- (command is make_py_avdu_aegis_checker or command is make_pykeepass_checker)
848
+ command
849
+ not in (
850
+ make_7zip_checker,
851
+ make_persistent_7zip_checker,
852
+ make_subprocess_checker,
853
+ make_password_candidate_piper,
854
+ )
649
855
  and not ns.print_passwords
650
- and not output_file
856
+ and not ns.output_file
651
857
  and ns.verbosity == 0
652
858
  ):
653
859
  warnings.warn(
654
- "The Keepass and the Aegis vault checkers do not decrypt files. "
860
+ "The SSH key, Keepass and the Aegis vault checkers do not decrypt files. "
655
861
  "When running fiddlesticks without print-passwords, without "
656
862
  "an output-file, and with verbosity=0, only the candidate number "
657
863
  "of any recovered password will be printed. "
@@ -660,7 +866,7 @@ def cli(args: list[str] = sys.argv[1:]) -> int:
660
866
  total, candidates = ns.password_generator(
661
867
  guesses=password_guesses, max_subs=ns.max_subs, alt_char_map=alt_char_map
662
868
  )
663
- checker = command(*extras, extract_to=extract_to)
869
+ checker = command(*extras, **kwargs)
664
870
 
665
871
  t0 = time.time()
666
872
 
@@ -678,14 +884,9 @@ def cli(args: list[str] = sys.argv[1:]) -> int:
678
884
  return 1
679
885
 
680
886
  password, i = result
681
- msg = f"\n Found password (guess number: {i}) in {t1 - t0:.3f} seconds"
682
- print_to_stderr(msg, end="")
683
887
 
684
- print_to_stderr(f" {password=}" if ns.print_passwords else "")
888
+ possibly_output_found_password(password, i, t1 - t0, **kwargs)
685
889
 
686
- if output_file:
687
- with open(output_file, "at") as f:
688
- f.write(f"{password}")
689
890
  return 0
690
891
 
691
892
 
@@ -0,0 +1,144 @@
1
+ import stat
2
+ import subprocess
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+
6
+ import pytest
7
+
8
+ from fiddlesticks import (
9
+ IS_WINDOWS,
10
+ _make_new_tmp_sub_dir,
11
+ check_passwords_sequentially,
12
+ make_MS_Office_files_key_checker,
13
+ make_py_avdu_aegis_checker,
14
+ make_pykeepass_checker,
15
+ make_ssh_key_checker,
16
+ make_subprocess_checker,
17
+ make_Veracrypt_checker,
18
+ )
19
+
20
+ from .helpers import (
21
+ DOCX_FILE,
22
+ KDBX_TEST_VAULT,
23
+ SEVEN_ZIP_TEST_ARCHIVE,
24
+ XLSX_FILE,
25
+ _assert_output_on_found_password,
26
+ _try_make_ssh_key_files,
27
+ _try_make_veracrypt_volume,
28
+ avdu_test_vault, # noqa: F401
29
+ )
30
+
31
+
32
+ @pytest.mark.skipif(
33
+ IS_WINDOWS, reason="I haven't figured out the 7zip CLI on Windows yet"
34
+ )
35
+ def test_is_7zip_installed():
36
+ args = ["7z", "--help"]
37
+ subprocess.run(args, capture_output=True, check=True)
38
+
39
+
40
+ def test_aegis_checker_against_avdu_vault(avdu_test_vault): # noqa: F811
41
+ checker = make_py_avdu_aegis_checker(avdu_test_vault)
42
+ # """
43
+ # # Run using the encrypted test file. (Enter password "test" when prompted.)
44
+ # go run ./cmd/avdu -p test/data/aegis_encrypted.json -e
45
+ # https://github.com/Sammy-T/avdu/blob/master/README.md
46
+ # """
47
+ assert checker("test")
48
+ assert not checker("wrong_password")
49
+
50
+
51
+ def test_pykeepass_checker_against_Test_vault():
52
+ checker = make_pykeepass_checker(KDBX_TEST_VAULT)
53
+ assert checker("test")
54
+ assert not checker("wrong_password")
55
+
56
+
57
+ def test_make_new_tmp_sub_dir(tmp_path, capsys):
58
+ _make_new_tmp_sub_dir(tmp_path)
59
+ # repeat to test while loop, and append a suffix.
60
+ _make_new_tmp_sub_dir(tmp_path)
61
+ capsys.readouterr()
62
+
63
+
64
+ def test_make_subprocess_checker(tmp_path):
65
+ script = tmp_path / "extract_with_7z.sh"
66
+ script.write_text(f"""\
67
+ #!/usr/bin/env bash
68
+ set -eu
69
+
70
+ 7z x -p$1 -o{tmp_path} {SEVEN_ZIP_TEST_ARCHIVE}
71
+ """)
72
+ script.chmod(script.stat().st_mode | stat.S_IXUSR | stat.S_IRUSR)
73
+ checker = make_subprocess_checker(f"{script} ")
74
+ assert checker("test")
75
+
76
+
77
+ def test_sequential_passwords_checker_verbosity_2(capsys):
78
+ result = check_passwords_sequentially(
79
+ candidates=[("A", 0), ("B", 0), ("C", 0)],
80
+ test_func=lambda candidate: False,
81
+ verbosity=2,
82
+ update_every=1,
83
+ total=3,
84
+ print_passwords=True,
85
+ )
86
+ capsys.readouterr()
87
+ assert result is None
88
+
89
+
90
+ def test_ssh_key_checker(tmp_path):
91
+ ssh_test_keys = _try_make_ssh_key_files(tmp_path)
92
+ for key_file, pw in ssh_test_keys:
93
+ assert key_file.is_file()
94
+ checker = make_ssh_key_checker(key_file)
95
+ assert not checker("incorrect")
96
+ assert checker(pw)
97
+
98
+
99
+ @pytest.mark.parametrize(
100
+ "print_passwords",
101
+ [True, False],
102
+ )
103
+ def test_ssh_key_checker_no_username_finds_password_on_init(
104
+ print_passwords: bool,
105
+ tmp_path,
106
+ capsys,
107
+ ):
108
+ keyfiles_and_pwds = _try_make_ssh_key_files(tmp_path, "password123")
109
+ with patch("getpass.getuser", side_effect=OSError):
110
+ for file, _pwd in keyfiles_and_pwds:
111
+ with pytest.raises(SystemExit):
112
+ make_ssh_key_checker(file, print_passwords=print_passwords)
113
+ stdout, stderr = capsys.readouterr()
114
+ _assert_output_on_found_password(
115
+ "password123", -12345, print_passwords, stdout, stderr
116
+ )
117
+
118
+
119
+ def test_ssh_key_checker_bad_file(tmp_path):
120
+ file = tmp_path / "test_bad_key"
121
+ file.write_bytes(b"oh2343gsbnwRPJWG32546OMPJDFAFSDGH&53423NZjiga")
122
+ with pytest.raises(ExceptionGroup):
123
+ make_ssh_key_checker(file)
124
+
125
+
126
+ @pytest.mark.parametrize("path", [XLSX_FILE, DOCX_FILE])
127
+ def test_ms_office_crypto_tool_checker(path: Path):
128
+ checker = make_MS_Office_files_key_checker(path)
129
+ assert not checker("not_test")
130
+ assert checker("test")
131
+
132
+
133
+ def test_veracrypt_checker(tmp_path):
134
+ volume = tmp_path / "test.hc"
135
+ password = "test"
136
+ # Default mount point ./mnt/veracrypt_volume{_X}
137
+ _try_make_veracrypt_volume(volume, password)
138
+
139
+ checker = make_Veracrypt_checker(file=volume)
140
+ assert not checker("not_test")
141
+
142
+ # This can fail if the volume is already mounted, or
143
+ # if something is already mounted to the same mount point.
144
+ assert checker("test")
@@ -21,6 +21,7 @@ from .helpers import (
21
21
  _assert_candidate_within_M_of_pwds,
22
22
  _assert_files_same,
23
23
  _create_password_protected_7z_archive,
24
+ _try_make_veracrypt_volume,
24
25
  avdu_test_vault, # noqa: F401
25
26
  chars_without_Bash_syntax,
26
27
  file_names_and_contents,
@@ -169,7 +170,20 @@ def test_default_command_with_aegis_vault(avdu_test_vault, tmp_path): # noqa: F
169
170
  _collate_args(2, ["7est"], avdu_test_vault),
170
171
  capture_output=True,
171
172
  check=False,
172
- env={"TMPDIR": str(tmp_path), **os.environ},
173
+ )
174
+ assert result.returncode == 0
175
+
176
+
177
+ def test_default_command_with_Veracrypt_volume(tmp_path):
178
+ volume = tmp_path / "test.hc"
179
+ password = "test"
180
+ # Default mount point ./mnt/veracrypt_volume{_X}
181
+ result = _try_make_veracrypt_volume(volume, password)
182
+
183
+ result = subprocess.run(
184
+ _collate_args(2, ["7est"], volume.as_posix()),
185
+ capture_output=True,
186
+ check=False,
173
187
  )
174
188
  assert result.returncode == 0
175
189
 
@@ -199,7 +213,7 @@ def test_input_and_output_file(tmp_path):
199
213
  "--py7zr",
200
214
  f"--input-file={guesses_file}",
201
215
  f"--output-file={output_file}",
202
- str(Path(__file__).parent / "foo.7z"),
216
+ str(SEVEN_ZIP_TEST_ARCHIVE),
203
217
  ],
204
218
  capture_output=True,
205
219
  check=True,
@@ -17,8 +17,11 @@ import fiddlesticks
17
17
 
18
18
  BI_MAP = fiddlesticks.SHIFT_AND_LEET_BI_MAP
19
19
  PARENT_DIR = Path(__file__).parent
20
- KDBX_TEST_VAULT = PARENT_DIR / "Test_vault_Do_Not_Use.kdbx"
21
- SEVEN_ZIP_TEST_ARCHIVE = PARENT_DIR / "foo.7z"
20
+ DATA_FILES = PARENT_DIR / "data_files"
21
+ KDBX_TEST_VAULT = DATA_FILES / "Test_vault_Do_Not_Use.kdbx"
22
+ SEVEN_ZIP_TEST_ARCHIVE = DATA_FILES / "foo.7z"
23
+ XLSX_FILE = DATA_FILES / "test.xlsx"
24
+ DOCX_FILE = DATA_FILES / "test.docx"
22
25
 
23
26
  file_names = text(
24
27
  alphabet=string.ascii_letters + string.digits,
@@ -174,7 +177,7 @@ def _assert_candidate_within_M_of_pwds(
174
177
 
175
178
 
176
179
  @pytest.fixture(scope="session")
177
- def avdu_test_vault(tmp_path_factory):
180
+ def avdu_test_vault(tmp_path_factory) -> Path:
178
181
  avdu_repo = tmp_path_factory.mktemp("avdu")
179
182
  subprocess.run(
180
183
  [
@@ -188,3 +191,81 @@ def avdu_test_vault(tmp_path_factory):
188
191
  capture_output=True,
189
192
  )
190
193
  return avdu_repo / "test" / "data" / "aegis_encrypted.json"
194
+
195
+
196
+ def _try_make_ssh_key_files(
197
+ keys_dir: Path,
198
+ password: str = "",
199
+ ) -> list[tuple[Path, str]]:
200
+ keyfiles_and_pwds = []
201
+ for cmd, pwd, key_file in [
202
+ (
203
+ 'openssl genrsa -aes128 -passout pass:{password} -out "{key_file}" 2048',
204
+ "test",
205
+ "openssl_PEM.key",
206
+ ),
207
+ (
208
+ 'ssh-keygen -m PEM -t rsa -b 2048 -N "{password}" -f "{key_file}"',
209
+ "testtest",
210
+ "basic_PEM.key",
211
+ ),
212
+ (
213
+ 'ssh-keygen -t rsa -b 2048 -N "{password}" -f "{key_file}"',
214
+ "testtest",
215
+ "openssh-modern.key",
216
+ ),
217
+ ]:
218
+ pwd = password or pwd
219
+ subprocess.run(
220
+ cmd.format(password=pwd, key_file=(keys_dir / key_file).as_posix()),
221
+ check=True,
222
+ capture_output=True,
223
+ shell=True,
224
+ )
225
+ keyfiles_and_pwds.append((keys_dir / key_file, pwd))
226
+ return keyfiles_and_pwds
227
+
228
+
229
+ def _assert_output_on_found_password(
230
+ password: str,
231
+ i: int,
232
+ print_passwords: bool,
233
+ stdout: str,
234
+ stderr: str,
235
+ ) -> None:
236
+ assert not stdout
237
+ assert "Found password" in stderr, f"{stderr=}"
238
+ assert f"candidate number: {i}" in stderr, f"{stderr=}"
239
+ if print_passwords:
240
+ assert password in stderr, f"{stderr=}"
241
+ else:
242
+ assert password not in stderr, f"{stderr=}"
243
+
244
+
245
+ def _try_make_veracrypt_volume(
246
+ volume: Path,
247
+ password: str,
248
+ ) -> subprocess.CompletedProcess:
249
+
250
+ args = [
251
+ "veracrypt",
252
+ "--text",
253
+ "--non-interactive",
254
+ "--create",
255
+ "--encryption=AES",
256
+ "--hash=SHA-512",
257
+ "--pim=0",
258
+ "--volume-type=normal",
259
+ "--filesystem=FAT",
260
+ "--keyfiles=",
261
+ "--size=512K",
262
+ f"--password={password}",
263
+ volume.as_posix(),
264
+ ]
265
+
266
+ return subprocess.run(
267
+ " ".join(args),
268
+ check=True,
269
+ capture_output=True,
270
+ shell=True,
271
+ )
@@ -0,0 +1,121 @@
1
+ import io
2
+ from pathlib import Path
3
+ from unittest.mock import patch
4
+
5
+ import msoffcrypto
6
+ import msoffcrypto.exceptions
7
+ import pytest
8
+ from cryptography.hazmat.primitives.serialization import (
9
+ load_pem_private_key,
10
+ load_ssh_private_key,
11
+ )
12
+
13
+ from fiddlesticks import (
14
+ IS_WINDOWS,
15
+ _get_hopefully_incorrect_password,
16
+ cli,
17
+ possibly_output_found_password,
18
+ )
19
+
20
+ from .helpers import (
21
+ DOCX_FILE,
22
+ SEVEN_ZIP_TEST_ARCHIVE,
23
+ XLSX_FILE,
24
+ _assert_output_on_found_password,
25
+ _try_make_ssh_key_files,
26
+ )
27
+
28
+
29
+ @pytest.mark.skipif(
30
+ IS_WINDOWS, reason="I haven't figured out the 7zip CLI on Windows yet"
31
+ )
32
+ def test_password_from_getpass_in_CLI(capsys):
33
+ with patch("getpass.getpass", side_effect=["test", ""]):
34
+ assert 0 == cli(["--max-subs", "0", str(SEVEN_ZIP_TEST_ARCHIVE)])
35
+ capsys.readouterr()
36
+
37
+
38
+ def test_get_hopefully_incorrect_password_username_not_found(capsys):
39
+ with patch("getpass.getuser", side_effect=OSError):
40
+ assert "password123" == _get_hopefully_incorrect_password()
41
+
42
+
43
+ @pytest.mark.parametrize(
44
+ "print_passwords",
45
+ [True, False],
46
+ )
47
+ def test_possibly_output_found_password_no_time(
48
+ print_passwords: bool,
49
+ capsys,
50
+ ):
51
+ password = "abcde"
52
+ i = 5
53
+ # This currently only gets called without a calculation time
54
+ # if the password was the user's username, or "password123",
55
+ # when using an SSH key password checker.
56
+ possibly_output_found_password(
57
+ password=password,
58
+ i=i,
59
+ t=None,
60
+ print_passwords=print_passwords,
61
+ )
62
+
63
+ stdout, stderr = capsys.readouterr()
64
+ _assert_output_on_found_password(password, i, print_passwords, stdout, stderr)
65
+
66
+
67
+ def test_are_error_strings_in_cryptography_unchanged(tmp_path):
68
+ keyfiles_and_pwds = _try_make_ssh_key_files(tmp_path, "testtesttest")
69
+ n = 0
70
+ for error_str, loader, file in [
71
+ (
72
+ "Corrupt data: broken checksum",
73
+ load_ssh_private_key,
74
+ keyfiles_and_pwds[-1][0],
75
+ ),
76
+ (
77
+ "Incorrect password, could not decrypt key",
78
+ load_pem_private_key,
79
+ keyfiles_and_pwds[1][0],
80
+ ),
81
+ # Seen other errors:
82
+ #
83
+ # 3
84
+ # =================================== FAILURES ===================================
85
+ # _______________ test_are_error_strings_in_cryptography_unchanged _______________
86
+ # tests/misc_tests.py:78: in test_are_error_strings_in_cryptography_unchanged
87
+ # loader(private_key_data, ***"the_wrong_password")
88
+ # E ValueError: Could not deserialize key data. The data may be in an incorrect format, it may be encrypted with an unsupported algorithm, or it may be an unsupported key type (e.g. EC curves with explicit parameters). Details: ASN.1 parsing error: unexpected tag (got Tag { value: 20, constructed: false, class: Application })
89
+ # E If your key is in PKCS#8 format, you must use BEGIN/END PRIVATE KEY PEM delimiters
90
+ #
91
+ # https://github.com/Hazardous-Area/fiddlesticks/actions/runs/33859787867/job/100981355309#logs
92
+ ]:
93
+ private_key_data = file.read_bytes()
94
+ try:
95
+ loader(private_key_data, password=b"the_wrong_password")
96
+ except ValueError as e:
97
+ assert e.args[0] == error_str, (
98
+ "The error string in the Cryptography dependency has changed. "
99
+ )
100
+ n += 1
101
+
102
+ assert n == 2, (
103
+ "SSH Key file loaded via cryptography primitive with the_wrong_password??!!"
104
+ )
105
+
106
+
107
+ @pytest.mark.parametrize("path", [XLSX_FILE, DOCX_FILE])
108
+ def test_msoffice_crypto_tools(path: Path):
109
+ # Not so different to msoffcrypto-tool's example:
110
+ # https://github.com/nolze/msoffcrypto-tool#as-library
111
+ encrypted = io.BytesIO(path.read_bytes())
112
+ stream = io.BytesIO()
113
+
114
+ office_file = msoffcrypto.OfficeFile(encrypted)
115
+
116
+ office_file.load_key(password="not_test")
117
+ with pytest.raises(msoffcrypto.exceptions.InvalidKeyError):
118
+ office_file.decrypt(stream)
119
+
120
+ office_file.load_key(password="test")
121
+ office_file.decrypt(stream)
@@ -1,12 +1,9 @@
1
- from unittest.mock import patch
2
-
3
1
  import pytest
4
2
  from hypothesis import HealthCheck, given, settings
5
3
 
6
4
  import fiddlesticks
7
5
 
8
6
  from .helpers import (
9
- SEVEN_ZIP_TEST_ARCHIVE,
10
7
  _assert_candidate_within_M_of_pwds,
11
8
  _candidate_is_within_M_of_pwd,
12
9
  passwords_guesses_and_num_subs,
@@ -56,11 +53,3 @@ def test_multiple_guesses(pwds):
56
53
  _total, candidates = fiddlesticks.candidate_passwords_from_alt_chars(pwds, 2)
57
54
  for candidate, _num_subs in candidates:
58
55
  _assert_candidate_within_M_of_pwds(candidate, pwds, 2)
59
-
60
-
61
- @pytest.mark.skipif(
62
- fiddlesticks.IS_WINDOWS, reason="I haven't figured out the 7zip CLI on Windows yet"
63
- )
64
- def test_password_from_getpass_in_CLI(capsys):
65
- with patch("getpass.getpass", side_effect=["test", ""]):
66
- assert 0 == fiddlesticks.cli(["--max-subs", "0", str(SEVEN_ZIP_TEST_ARCHIVE)])
@@ -1,2 +0,0 @@
1
- * text eol=lf
2
- *.kdbx,*.7z binary
@@ -1,27 +0,0 @@
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
-
12
- steps:
13
- - name: Check out repo
14
- uses: actions/checkout@v7
15
-
16
- - name: Set up Python
17
- uses: actions/setup-python@v7
18
- with:
19
- python-version: "3.14"
20
-
21
- - name: Install dependencies
22
- run: |
23
- pip install -e . --group=test
24
-
25
- - name: Run PyTest (via Coverage)
26
- run: |
27
- coverage run -m pytest -rA --tb=short tests && coverage combine && coverage report
@@ -1,75 +0,0 @@
1
- import stat
2
- import subprocess
3
-
4
- import pytest
5
-
6
- from fiddlesticks import (
7
- IS_WINDOWS,
8
- _make_new_tmp_sub_dir,
9
- check_passwords_sequentially,
10
- make_py_avdu_aegis_checker,
11
- make_pykeepass_checker,
12
- make_subprocess_checker,
13
- )
14
-
15
- from .helpers import (
16
- KDBX_TEST_VAULT,
17
- SEVEN_ZIP_TEST_ARCHIVE,
18
- avdu_test_vault, # noqa: F401
19
- )
20
-
21
-
22
- @pytest.mark.skipif(
23
- IS_WINDOWS, reason="I haven't figured out the 7zip CLI on Windows yet"
24
- )
25
- def test_is_7zip_installed():
26
- args = ["7z", "--help"]
27
- subprocess.run(args, capture_output=True, check=True)
28
-
29
-
30
- def test_aegis_checker_against_avdu_vault(avdu_test_vault): # noqa: F811
31
- checker = make_py_avdu_aegis_checker(avdu_test_vault)
32
- # """
33
- # # Run using the encrypted test file. (Enter password "test" when prompted.)
34
- # go run ./cmd/avdu -p test/data/aegis_encrypted.json -e
35
- # https://github.com/Sammy-T/avdu/blob/master/README.md
36
- # """
37
- assert checker("test")
38
- assert not checker("wrong_password")
39
-
40
-
41
- def test_pykeepass_checker_against_Test_vault():
42
- checker = make_pykeepass_checker(KDBX_TEST_VAULT)
43
- assert checker("test")
44
- assert not checker("wrong_password")
45
-
46
-
47
- def test_make_new_tmp_sub_dir(tmp_path, capsys):
48
- _make_new_tmp_sub_dir("", tmp_path)
49
- # repeat to test while loop, and append a suffix.
50
- _make_new_tmp_sub_dir("", tmp_path)
51
-
52
-
53
- def test_make_subprocess_checker(tmp_path):
54
- script = tmp_path / "extract_with_7z.sh"
55
- script.write_text(f"""\
56
- #!/usr/bin/env bash
57
- set -eu
58
-
59
- 7z x -p$1 -o{tmp_path} {SEVEN_ZIP_TEST_ARCHIVE}
60
- """)
61
- script.chmod(script.stat().st_mode | stat.S_IXUSR | stat.S_IRUSR)
62
- checker = make_subprocess_checker(f"{script} ")
63
- assert checker("test")
64
-
65
-
66
- def test_sequential_passwords_checker_verbosity_2(capsys):
67
- result = check_passwords_sequentially(
68
- candidates=[("A", 0), ("B", 0), ("C", 0)],
69
- test_func=lambda candidate: False,
70
- verbosity=2,
71
- update_every=1,
72
- total=3,
73
- print_passwords=True,
74
- )
75
- assert result is None
File without changes
File without changes
File without changes