diff --git a/.gitignore b/.gitignore index 9869655..9efc18a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ hate_crack/princeprocessor/ *.ollama_candidates *.filtered research/ +--help +4_char_all +all_hashes.enabled +some_histories diff --git a/README.md b/README.md index 702cbb9..9268ed7 100644 --- a/README.md +++ b/README.md @@ -618,7 +618,10 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi (16) OMEN Attack (17) Ad-hoc Mask Attack (18) Markov Brute Force Attack - (21) Combipow Passphrase Attack + (19) N-gram Attack + (20) Permutation Attack + (21) Random Rules Attack + (22) Combipow Passphrase Attack (90) Download rules from Hashmob.net (91) Analyze Hashcat Rules @@ -764,11 +767,13 @@ Uses the Ordered Markov ENumerator (OMEN) to train a statistical password model * Model files and metadata are stored in `~/.hate_crack/omen/` for persistence across sessions #### Combinator Attacks Submenu -Opens an interactive submenu with four combinator attack variants (formerly at menu keys 10-12). Consolidates related attacks for cleaner menu organization: +Opens an interactive submenu with six combinator attack variants (formerly at menu keys 10-12). Consolidates related attacks for cleaner menu organization: - Combinator Attack - combines two wordlists - YOLO Combinator Attack - combines all permutations of multiple wordlists - Middle Combinator Attack - combines wordlists with an extra word in the middle - Thorough Combinator Attack - comprehensive combination of wordlists with rules +- Combinator3 Attack - combines exactly 3 wordlists using `combinator3.bin`, generating all `word1+word2+word3` combinations piped to hashcat +- CombinatorX Attack - combines 2-8 wordlists using `combinatorX.bin` with optional `--sepFill` separator character between word segments #### Ad-hoc Mask Attack Runs hashcat mask attack (mode 3) with a user-specified custom mask string. Allows fine-grained control over character-set brute forcing. @@ -790,6 +795,22 @@ Generates password candidates using Markov chain statistical models. Similar to * Markov table persists with hash file (filename.out.hcstat2) for fast subsequent runs * Faster than OMEN for general-purpose brute forcing +#### Permutation Attack +Generates all character permutations of each word in a targeted wordlist and pipes them to hashcat via `permute.bin` from hashcat-utils. + +* Prompts for a single wordlist file (not a directory) +* Effective against short targeted wordlists where the character set is known but the order is not (company abbreviations, name fragments, known tokens) +* WARNING: Scales as N! per word - an 8-character word produces 40,320 permutations. Only practical for words up to ~8 characters. +* Uses `permute.bin < wordlist | hashcat` pipeline pattern + +#### Random Rules Attack +Generates a set of random hashcat mutation rules using `generate-rules.bin`, writes them to a temporary file, then runs hashcat against a chosen wordlist with those rules. + +* Prompts for rule count (default 65536) +* Prompts for wordlist path with tab-completion and numbered selection +* Temporary rules file is cleaned up after the run regardless of outcome +* Useful when known rule sets are exhausted - explores random rule-space for additional cracks + #### Combipow Passphrase Attack Generates all unique non-empty subset combinations from a short wordlist using `combipow.bin` and pipes them into hashcat. Designed for passphrase cracking when you know the pool of words a password was built from. @@ -834,6 +855,7 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi ### Version History Version 2.0+ + - Added Random Rules Attack (option 20) using `generate-rules.bin` to generate random mutation rules (#87) - Added Ad-hoc Mask Attack (option 17) for user-typed hashcat masks with optional custom character sets - Added Markov Brute Force Attack (option 18) using `hcstat2` statistical tables for password generation - Consolidated Combinator Attacks (formerly options 10/11/12) into interactive submenu under option 6 diff --git a/config.json.example b/config.json.example index a2804ca..5431016 100644 --- a/config.json.example +++ b/config.json.example @@ -8,6 +8,8 @@ "rules_directory": "./hashcat/rules", "hcatDictionaryWordlist": ["rockyou.txt"], "hcatCombinationWordlist": ["rockyou.txt","rockyou.txt"], + "hcatCombinator3Wordlist": ["rockyou.txt","rockyou.txt","rockyou.txt"], + "hcatCombinatorXWordlist": ["rockyou.txt","rockyou.txt"], "hcatHybridlist": ["rockyou.txt"], "hcatMiddleCombinatorMasks": ["2","4"," ","-","_","+",",",".","&"], "hcatMiddleBaseList": "rockyou.txt", diff --git a/hate_crack.py b/hate_crack.py index e292a7b..fbb78b6 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -88,7 +88,10 @@ def get_main_menu_options(): "16": _attacks.omen_attack, "17": _attacks.adhoc_mask_crack, "18": _attacks.markov_brute_force, - "21": _attacks.combipow_crack, + "19": _attacks.ngram_attack, + "20": _attacks.permute_crack, + "21": _attacks.generate_rules_crack, + "22": _attacks.combipow_crack, "90": download_hashmob_rules, "91": weakpass_wordlist_menu, "92": download_hashmob_wordlists, diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index e8d70c4..6b79f42 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -1,10 +1,12 @@ import glob +import gzip import os import readline from typing import Any from hate_crack.api import download_hashmob_rules from hate_crack.formatting import print_multicolumn_list +from hate_crack.menu import interactive_menu def _configure_readline(completer): @@ -261,8 +263,7 @@ def combinator_crack(ctx: Any) -> None: print("\n" + "=" * 60) print("COMBINATOR ATTACK") print("=" * 60) - print("This attack combines two wordlists to generate candidates.") - print("Example: wordlist1='password' + wordlist2='123' = 'password123'") + print("Combines 2-8 wordlists. 2 uses hashcat native mode; 3+ use external binaries.") print("=" * 60) use_default = ( @@ -270,73 +271,30 @@ def combinator_crack(ctx: Any) -> None: ) if use_default != "n": - print("\nUsing default wordlist(s) from config:") - if isinstance(ctx.hcatCombinationWordlist, list): - for wl in ctx.hcatCombinationWordlist: - print(f" - {wl}") - wordlists = ctx.hcatCombinationWordlist - else: - print(f" - {ctx.hcatCombinationWordlist}") - wordlists = [ctx.hcatCombinationWordlist] - else: - print("\nSelect wordlists for combinator attack.") - print("You need to provide exactly 2 wordlists.") - print("You can enter:") - print(" - Two file paths separated by commas") - print(" - Press TAB to autocomplete file paths") - - selection = ctx.select_file_with_autocomplete( - "Enter 2 wordlist files (comma-separated)", - allow_multiple=True, - base_dir=ctx.hcatWordlists, - ) - - if not selection: - print("No wordlists selected. Aborting combinator attack.") + base = ctx.hcatCombinationWordlist + wordlists = base if isinstance(base, list) else [base] + wordlists = [ctx._resolve_wordlist_path(wl, ctx.hcatWordlists) for wl in wordlists] + if len(wordlists) < 2: + print("\n[!] Config does not have at least 2 wordlists.") + print("Set hcatCombinationWordlist to a list of 2+ paths in config.json.") + print("Aborting combinator attack.") return - - if isinstance(selection, str): - wordlists = [selection] - else: - wordlists = selection - + separator = "" + else: + print("\nEnter 2-8 wordlists. Enter a blank line when done.") + wordlists = _prompt_wordlist_paths(ctx, max_count=8) if len(wordlists) < 2: print("\n[!] Combinator attack requires at least 2 wordlists.") print("Aborting combinator attack.") return + separator = input("\nEnter separator between words (leave blank for none): ").strip() - valid_wordlists = [] - for wl in wordlists[:2]: # Only use first 2 - resolved = ctx._resolve_wordlist_path(wl, ctx.hcatWordlists) - if os.path.isfile(resolved): - valid_wordlists.append(resolved) - print(f"✓ Found: {resolved}") - else: - print(f"✗ Not found: {resolved}") - - if len(valid_wordlists) < 2: - print("\nCould not find 2 valid wordlists. Aborting combinator attack.") - return - - wordlists = valid_wordlists - - wordlists = [ - ctx._resolve_wordlist_path(wl, ctx.hcatWordlists) for wl in wordlists[:2] - ] - - if len(wordlists) < 2: - print("\n[!] Combinator attack requires 2 wordlists but only 1 is configured.") - print("Set hcatCombinationWordlist to a list of 2 paths in config.json.") - print("Aborting combinator attack.") - return - - print("\nStarting combinator attack with 2 wordlists:") - print(f" Wordlist 1: {wordlists[0]}") - print(f" Wordlist 2: {wordlists[1]}") - print(f"Hash type: {ctx.hcatHashType}") - print(f"Hash file: {ctx.hcatHashFile}") - - ctx.hcatCombination(ctx.hcatHashType, ctx.hcatHashFile, wordlists) + if len(wordlists) == 2 and not separator: + ctx.hcatCombination(ctx.hcatHashType, ctx.hcatHashFile, wordlists) + elif len(wordlists) == 3 and not separator: + ctx.hcatCombinator3(ctx.hcatHashType, ctx.hcatHashFile, wordlists) + else: + ctx.hcatCombinatorX(ctx.hcatHashType, ctx.hcatHashFile, wordlists, separator or None) def hybrid_crack(ctx: Any) -> None: @@ -427,6 +385,66 @@ def middle_combinator(ctx: Any) -> None: ctx.hcatMiddleCombinator(ctx.hcatHashType, ctx.hcatHashFile) +def _prompt_wordlist_paths(ctx, max_count: int) -> list[str]: + """Prompt for wordlist paths one at a time with tab-autocomplete. + + Stops when a blank line is entered or max_count paths have been collected. + Returns a list of resolved, valid file paths. + """ + + def path_completer(text, state): + base = ctx.hcatWordlists + if not text: + pattern = os.path.join(base, "*") + matches = glob.glob(pattern) + else: + expanded = os.path.expanduser(text) + if expanded.startswith(("/", "./", "../", "~")): + matches = glob.glob(expanded + "*") + else: + pattern = os.path.join(base, expanded + "*") + matches = glob.glob(pattern) + matches = [m + "/" if os.path.isdir(m) else m for m in matches] + try: + return matches[state] + except IndexError: + return None + + _configure_readline(path_completer) + + collected: list[str] = [] + count = 1 + while len(collected) < max_count: + raw = input( + f"\nWordlist #{count} (tab to autocomplete, blank to finish): " + ).strip() + if not raw: + break + resolved = ctx._resolve_wordlist_path(raw, ctx.hcatWordlists) + if os.path.isfile(resolved): + collected.append(resolved) + print(f"Added: {resolved}") + count += 1 + else: + print(f"Not found: {resolved}") + return collected + + +def combinator3_crack(ctx: Any) -> None: + """3-way combinator attack (delegates to unified combinator_crack).""" + combinator_crack(ctx) + + +def combinatorX_crack(ctx: Any) -> None: + """N-way combinator attack (delegates to unified combinator_crack).""" + combinator_crack(ctx) + + +def combinator_3plus_crack(ctx: Any) -> None: + """3+ wordlist combinator (delegates to unified combinator_crack).""" + combinator_crack(ctx) + + def bandrel_method(ctx: Any) -> None: ctx.hcatBandrel(ctx.hcatHashType, ctx.hcatHashFile) @@ -629,7 +647,7 @@ def combipow_crack(ctx: Any) -> None: if not os.path.isfile(path): print(f"[!] File not found: {path}") continue - with open(path) as fh: + with (gzip.open(path, "rb") if path.endswith(".gz") else open(path, "rb")) as fh: line_count = sum(1 for _ in fh) if line_count > 63: print( @@ -645,11 +663,160 @@ def combipow_crack(ctx: Any) -> None: ctx.hcatCombipow(ctx.hcatHashType, ctx.hcatHashFile, wordlist, use_space_sep) -def combinator_submenu(ctx: Any) -> None: - from hate_crack.menu import interactive_menu +def generate_rules_crack(ctx: Any) -> None: + print("\n" + "=" * 60) + print("RANDOM RULES ATTACK") + print("=" * 60) + print("Generates random hashcat mutation rules and applies them to a wordlist.") + print("Use when known rulesets are exhausted - a chaos mode for rule-space exploration.") + print("=" * 60) + raw_count = input("\nNumber of random rules to generate (65536): ").strip() + try: + rule_count = int(raw_count) if raw_count else 65536 + if rule_count < 1: + print("[!] Rule count must be at least 1.") + return + except ValueError: + print("[!] Invalid rule count.") + return + + wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) + wordlist_entries = [ + f"{i}. {file}" for i, file in enumerate(wordlist_files, start=1) + ] + max_entry_len = max((len(e) for e in wordlist_entries), default=24) + print_multicolumn_list( + "Wordlists", + wordlist_entries, + min_col_width=max_entry_len, + max_col_width=max_entry_len, + ) + + def path_completer(text, state): + base = ctx.hcatWordlists + if not text: + pattern = os.path.join(base, "*") + matches = glob.glob(pattern) + else: + text = os.path.expanduser(text) + if text.startswith(("/", "./", "../", "~")): + matches = glob.glob(text + "*") + else: + pattern = os.path.join(base, text + "*") + matches = glob.glob(pattern) + matches = [m + "/" if os.path.isdir(m) else m for m in matches] + try: + return matches[state] + except IndexError: + return None + + _configure_readline(path_completer) + + wordlist_choice = None + while wordlist_choice is None: + try: + raw_choice = input( + "\nEnter path of wordlist (tab to autocomplete).\n" + f"Press Enter for default wordlist directory [{ctx.hcatWordlists}]: " + ) + raw_choice = raw_choice.strip() + if raw_choice == "": + wordlist_choice = ctx.hcatWordlists + elif raw_choice.isdigit() and 1 <= int(raw_choice) <= len(wordlist_files): + chosen = os.path.join( + ctx.hcatWordlists, wordlist_files[int(raw_choice) - 1] + ) + if os.path.exists(chosen): + wordlist_choice = chosen + print(wordlist_choice) + elif os.path.exists(raw_choice): + wordlist_choice = raw_choice + else: + print("[!] Wordlist not found. Please enter a valid path.") + return + except ValueError: + print("Please enter a valid number.") + + ctx.hcatGenerateRules(ctx.hcatHashType, ctx.hcatHashFile, rule_count, wordlist_choice) + + +def ngram_attack(ctx: Any) -> None: + print("\n" + "=" * 60) + print("NGRAM ATTACK") + print("=" * 60) + print("Generates n-gram candidates from a corpus file via ngramX.bin.") + print("Gzip-compressed corpus files are auto-detected and decompressed.") + print("=" * 60) + + corpus = ctx.select_file_with_autocomplete( + "Select corpus file (tab to autocomplete)", + base_dir=ctx.hcatWordlists, + ) + if not corpus: + print("No corpus selected. Aborting ngram attack.") + return + + group_size_raw = input("\nEnter n-gram group size (default 3): ").strip() + try: + group_size = int(group_size_raw) if group_size_raw else 3 + except ValueError: + print("[!] Invalid group size. Using default of 3.") + group_size = 3 + + ctx.hcatNgramX(ctx.hcatHashType, ctx.hcatHashFile, corpus, group_size) + + +def permute_crack(ctx: Any) -> None: + print("\n" + "=" * 60) + print("PERMUTATION ATTACK") + print("=" * 60) + print("Generates ALL character permutations of each word in a targeted wordlist.") + print("WARNING: Scales as N! per word. Only practical for words up to ~8 characters.") + print("Best for: short targeted wordlists (names, abbreviations, known fragments).") + print("=" * 60) + + def path_completer(text, state): + base = ctx.hcatWordlists + if not text: + pattern = os.path.join(base, "*") + matches = glob.glob(pattern) + else: + text = os.path.expanduser(text) + if text.startswith(("/", "./", "../", "~")): + matches = glob.glob(text + "*") + else: + pattern = os.path.join(base, text + "*") + matches = glob.glob(pattern) + matches = [m + "/" if os.path.isdir(m) else m for m in matches] + try: + return matches[state] + except IndexError: + return None + + _configure_readline(path_completer) + + wordlist_path = None + while wordlist_path is None: + raw = input( + "\nEnter path to a wordlist FILE (tab to autocomplete): " + ).strip() + if not raw: + continue + if not os.path.exists(raw): + print(f"[!] Path not found: {raw}") + continue + if os.path.isdir(raw): + print("[!] A directory was provided. Please enter a single wordlist file.") + continue + wordlist_path = raw + + ctx.hcatPermute(ctx.hcatHashType, ctx.hcatHashFile, wordlist_path) + + +def combinator_submenu(ctx: Any) -> None: items = [ - ("1", "Combinator Attack"), + ("1", "Combinator Attack (2-8 wordlists)"), ("2", "YOLO Combinator Attack"), ("3", "Middle Combinator Attack"), ("4", "Thorough Combinator Attack"), diff --git a/hate_crack/main.py b/hate_crack/main.py index 6536a1d..6897c69 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -23,7 +23,10 @@ import time import argparse import urllib.request import urllib.error +import contextlib +import gzip import lzma +import tempfile from types import SimpleNamespace #!/usr/bin/env python3 @@ -356,19 +359,19 @@ else: def _append_potfile_arg(cmd, *, use_potfile_path=True, potfile_path=None): - if not use_potfile_path: - return - pot = potfile_path or hcatPotfilePath - if pot: - try: - pot_dir = os.path.dirname(pot) - if pot_dir: - os.makedirs(pot_dir, exist_ok=True) - if not os.path.exists(pot): - open(pot, "a").close() - except OSError: - pass - cmd.append(f"--potfile-path={pot}") + if use_potfile_path: + pot = potfile_path or hcatPotfilePath + if pot: + try: + pot_dir = os.path.dirname(pot) + if pot_dir: + os.makedirs(pot_dir, exist_ok=True) + if not os.path.exists(pot): + open(pot, "a").close() + except OSError: + pass + cmd.append(f"--potfile-path={pot}") + _debug_cmd(cmd) rulesDirectory = config_parser["rules_directory"] @@ -401,6 +404,8 @@ pipalPath = config_parser["pipalPath"] hcatDictionaryWordlist = config_parser["hcatDictionaryWordlist"] hcatHybridlist = config_parser["hcatHybridlist"] hcatCombinationWordlist = config_parser["hcatCombinationWordlist"] +hcatCombinator3Wordlist = config_parser.get("hcatCombinator3Wordlist", ["rockyou.txt", "rockyou.txt", "rockyou.txt"]) +hcatCombinatorXWordlist = config_parser.get("hcatCombinatorXWordlist", ["rockyou.txt", "rockyou.txt"]) hcatMiddleCombinatorMasks = config_parser["hcatMiddleCombinatorMasks"] hcatMiddleBaseList = config_parser["hcatMiddleBaseList"] hcatThoroughCombinatorMasks = config_parser["hcatThoroughCombinatorMasks"] @@ -558,6 +563,8 @@ hcatDictionaryWordlist = _normalize_wordlist_setting( hcatCombinationWordlist = _normalize_wordlist_setting( hcatCombinationWordlist, wordlists_dir ) +hcatCombinator3Wordlist = _normalize_wordlist_setting(hcatCombinator3Wordlist, wordlists_dir) +hcatCombinatorXWordlist = _normalize_wordlist_setting(hcatCombinatorXWordlist, wordlists_dir) hcatHybridlist = _normalize_wordlist_setting(hcatHybridlist, wordlists_dir) hcatMiddleBaseList = _normalize_wordlist_setting(hcatMiddleBaseList, wordlists_dir) hcatThoroughBaseList = _normalize_wordlist_setting(hcatThoroughBaseList, wordlists_dir) @@ -674,13 +681,25 @@ hcatDictionaryCount = 0 hcatMaskCount = 0 hcatFingerprintCount = 0 hcatCombinationCount = 0 +hcatCombinator3Count = 0 +hcatCombinatorXCount = 0 +hcatNgramXCount = 0 hcatHybridCount = 0 hcatExtraCount = 0 hcatRecycleCount = 0 +hcatGenerateRulesCount = 0 +hcatPermuteCount = 0 hcatProcess: subprocess.Popen[Any] | None = None debug_mode = False +def _open_wordlist(path): + """Open a wordlist file, transparently decompressing gzip if the path ends with .gz.""" + if path.endswith(".gz"): + return gzip.open(path, "rb") + return open(path, "rb") + + def _format_cmd(cmd): # Shell-style quoting to mirror what a user could run in a terminal. return " ".join(shlex.quote(str(part)) for part in cmd) @@ -691,6 +710,37 @@ def _debug_cmd(cmd): print(f"[DEBUG] hashcat cmd: {_format_cmd(cmd)}") +def _is_gzipped(path: str) -> bool: + try: + with open(path, "rb") as f: + return f.read(2) == b"\x1f\x8b" + except OSError: + return False + + +@contextlib.contextmanager +def _wordlist_path(path: str): + """Yield an uncompressed path for path. + + If the file is gzip-compressed, decompress to a temp file and clean up on + exit. Otherwise yield the original path unchanged. + """ + if _is_gzipped(path): + with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as tmp: + tmp_name = tmp.name + with gzip.open(path, "rb") as gz_in: + shutil.copyfileobj(gz_in, tmp) + try: + yield tmp_name + finally: + try: + os.unlink(tmp_name) + except OSError: + pass + else: + yield path + + def _add_debug_mode_for_rules(cmd): """Add debug mode arguments to hashcat command if rules are being used. @@ -1415,6 +1465,125 @@ def hcatCombination(hcatHashType, hcatHashFile, wordlists=None): hcatCombinationCount = lineCount(hcatHashFile + ".out") - hcatHashCracked +# Combinator3 Attack - 3-way combination via combinator3.bin piped to hashcat +def hcatCombinator3(hcatHashType, hcatHashFile, wordlists): + global hcatCombinator3Count + global hcatProcess + + if len(wordlists) < 3: + print("[!] Combinator3 attack requires exactly 3 wordlists.") + return + + combinator3_bin = os.path.join(hate_path, "hashcat-utils/bin/combinator3.bin") + with contextlib.ExitStack() as stack: + resolved = [stack.enter_context(_wordlist_path(w)) for w in wordlists[:3]] + generator_cmd = [combinator3_bin] + resolved + hashcat_cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(), + "-o", + f"{hcatHashFile}.out", + ] + hashcat_cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(hashcat_cmd) + generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE) + assert generator_proc.stdout is not None + hcatProcess = subprocess.Popen(hashcat_cmd, stdin=generator_proc.stdout) + generator_proc.stdout.close() + try: + hcatProcess.wait() + generator_proc.wait() + except KeyboardInterrupt: + print("Killing PID {0}...".format(str(hcatProcess.pid))) + hcatProcess.kill() + generator_proc.kill() + + hcatCombinator3Count = lineCount(hcatHashFile + ".out") - hcatHashCracked + + +# CombinatorX Attack - N-way combination (2-8 wordlists) via combinatorX.bin piped to hashcat +def hcatCombinatorX(hcatHashType, hcatHashFile, wordlists, separator=None): + global hcatCombinatorXCount + global hcatProcess + + if len(wordlists) < 2: + print("[!] CombinatorX attack requires at least 2 wordlists.") + return + + combinatorX_bin = os.path.join(hate_path, "hashcat-utils/bin/combinatorX.bin") + with contextlib.ExitStack() as stack: + resolved = [stack.enter_context(_wordlist_path(w)) for w in wordlists[:8]] + generator_cmd = [combinatorX_bin] + for i, f in enumerate(resolved, start=1): + generator_cmd += [f"--file{i}", f] + if separator: + generator_cmd += ["--sepFill", separator] + hashcat_cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(), + "-o", + f"{hcatHashFile}.out", + ] + hashcat_cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(hashcat_cmd) + generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE) + assert generator_proc.stdout is not None + hcatProcess = subprocess.Popen(hashcat_cmd, stdin=generator_proc.stdout) + generator_proc.stdout.close() + try: + hcatProcess.wait() + generator_proc.wait() + except KeyboardInterrupt: + print("Killing PID {0}...".format(str(hcatProcess.pid))) + hcatProcess.kill() + generator_proc.kill() + + hcatCombinatorXCount = lineCount(hcatHashFile + ".out") - hcatHashCracked + + +# NgramX Attack - n-gram candidates from corpus file piped to hashcat +def hcatNgramX(hcatHashType, hcatHashFile, corpus, group_size=3): + global hcatNgramXCount + global hcatProcess + + ngramX_bin = os.path.join(hate_path, "hashcat-utils/bin/ngramX.bin") + with _wordlist_path(corpus) as resolved_corpus: + generator_cmd = [ngramX_bin, resolved_corpus, str(group_size)] + hashcat_cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(), + "-o", + f"{hcatHashFile}.out", + ] + hashcat_cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(hashcat_cmd) + generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE) + assert generator_proc.stdout is not None + hcatProcess = subprocess.Popen(hashcat_cmd, stdin=generator_proc.stdout) + generator_proc.stdout.close() + try: + hcatProcess.wait() + generator_proc.wait() + except KeyboardInterrupt: + print("Killing PID {0}...".format(str(hcatProcess.pid))) + hcatProcess.kill() + generator_proc.kill() + + hcatNgramXCount = lineCount(hcatHashFile + ".out") - hcatHashCracked + + # Hybrid Attack def hcatHybrid(hcatHashType, hcatHashFile, wordlists=None): global hcatHybridCount @@ -2091,7 +2260,7 @@ def hcatMarkovTrain(source_file, hcatHashFile): return False try: - with open(source_file, "rb") as stdin_f: + with _open_wordlist(source_file) as stdin_f: hcatProcess = subprocess.Popen( [hcstat2gen_bin, hcstat2_path], stdin=stdin_f, stderr=subprocess.PIPE ) @@ -2182,10 +2351,21 @@ def hcatCombipow(hcatHashType, hcatHashFile, wordlist, use_space_sep=True): global hcatProcess, hcatCombipowCount hcatCombipowCount += 1 combipow_bin = os.path.join(hate_path, "hashcat-utils/bin/combipow.bin") + + tmp_file = None + if wordlist.endswith(".gz"): + tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".txt") + with gzip.open(wordlist, "rb") as gz_in: + tmp_file.write(gz_in.read()) + tmp_file.close() + wordlist_path = tmp_file.name + else: + wordlist_path = wordlist + generator_cmd = [combipow_bin] if use_space_sep: generator_cmd.append("-s") - generator_cmd.append(wordlist) + generator_cmd.append(wordlist_path) session_name = re.sub( r"[^a-zA-Z0-9_-]", "_", os.path.splitext(os.path.basename(hcatHashFile))[0] ) @@ -2211,6 +2391,10 @@ def hcatCombipow(hcatHashType, hcatHashFile, wordlist, use_space_sep=True): print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() generator_proc.kill() + finally: + if tmp_file is not None: + with contextlib.suppress(OSError): + os.unlink(tmp_file.name) # PRINCE Attack @@ -2248,7 +2432,7 @@ def hcatPrince(hcatHashType, hcatHashFile): hashcat_cmd.extend(shlex.split(hcatTuning)) _append_potfile_arg(hashcat_cmd) hashcat_cmd = _add_debug_mode_for_rules(hashcat_cmd) - with open(prince_base, "rb") as base: + with _open_wordlist(prince_base) as base: prince_proc = subprocess.Popen(prince_cmd, stdin=base, stdout=subprocess.PIPE) hcatProcess = subprocess.Popen(hashcat_cmd, stdin=prince_proc.stdout) prince_proc.stdout.close() @@ -2261,6 +2445,45 @@ def hcatPrince(hcatHashType, hcatHashFile): prince_proc.kill() +def hcatPermute(hcatHashType, hcatHashFile, wordlist): + global hcatProcess, hcatPermuteCount + permute_path = os.path.join(hate_path, "hashcat-utils", "bin", "permute.bin") + if not os.path.isfile(permute_path): + print(f"Error: permute.bin not found: {permute_path}") + return + if not os.path.isfile(wordlist): + print(f"Error: wordlist not found: {wordlist}") + return + hashcat_cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(), + "-o", + f"{hcatHashFile}.out", + ] + hashcat_cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(hashcat_cmd) + with _open_wordlist(wordlist) as wl_file: + permute_proc = subprocess.Popen( + [permute_path], stdin=wl_file, stdout=subprocess.PIPE + ) + hcatProcess = subprocess.Popen( + hashcat_cmd, stdin=permute_proc.stdout + ) + permute_proc.stdout.close() + try: + hcatProcess.wait() + permute_proc.wait() + except KeyboardInterrupt: + print(f"Killing PID {hcatProcess.pid}...") + hcatProcess.kill() + permute_proc.kill() + hcatPermuteCount = lineCount(f"{hcatHashFile}.out") - hcatHashCracked + + # OMEN model directory - writable location for trained model files. # The binaries live in {hate_path}/omen/ (possibly read-only after install), # but model output (createConfig, *.level) goes to ~/.hate_crack/omen/. @@ -2566,6 +2789,51 @@ def hcatRecycle(hcatHashType, hcatHashFile, hcatNewPasswords): hcatProcess.kill() +def hcatGenerateRules(hcatHashType, hcatHashFile, rule_count, wordlist): + global hcatProcess, hcatGenerateRulesCount + generate_rules_path = os.path.join( + hate_path, "hashcat-utils", "bin", "generate-rules.bin" + ) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".rule", prefix="hate_crack_random_", delete=False + ) as rules_file: + rules_path = rules_file.name + try: + result = subprocess.run( + [generate_rules_path, str(rule_count)], + capture_output=True, + text=True, + check=True, + ) + with open(rules_path, "w") as f: + f.write(result.stdout) + cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(), + "-o", + f"{hcatHashFile}.out", + "-r", + rules_path, + wordlist, + ] + cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(cmd) + hcatProcess = subprocess.Popen(cmd) + try: + hcatProcess.wait() + except KeyboardInterrupt: + print(f"Killing PID {hcatProcess.pid}...") + hcatProcess.kill() + finally: + if os.path.exists(rules_path): + os.unlink(rules_path) + hcatGenerateRulesCount = lineCount(hcatHashFile + ".out") - hcatHashCracked + + def check_potfile(): print("Checking POT file for already cracked hashes...") _run_hashcat_show(hcatHashType, hcatHashFile, f"{hcatHashFile}.out") @@ -3340,6 +3608,22 @@ def middle_combinator(): return _attacks.middle_combinator(_attack_ctx()) +def combinator3_crack(): + return _attacks.combinator3_crack(_attack_ctx()) + + +def combinatorX_crack(): + return _attacks.combinatorX_crack(_attack_ctx()) + + +def combinator_3plus_crack(): + return _attacks.combinator_3plus_crack(_attack_ctx()) + + +def ngram_attack(): + return _attacks.ngram_attack(_attack_ctx()) + + def combinator_submenu(): return _attacks.combinator_submenu(_attack_ctx()) @@ -3372,6 +3656,14 @@ def combipow_crack(): return _attacks.combipow_crack(_attack_ctx()) +def generate_rules_crack(): + return _attacks.generate_rules_crack(_attack_ctx()) + + +def permute_crack(): + return _attacks.permute_crack(_attack_ctx()) + + # convert hex words for recycling def convert_hex(working_file): processed_words = [] @@ -3600,7 +3892,10 @@ def get_main_menu_items(): ("16", "OMEN Attack"), ("17", "Ad-hoc Mask Attack"), ("18", "Markov Brute Force Attack"), - ("21", "Combipow Passphrase Attack"), + ("19", "N-gram Attack"), + ("20", "Permutation Attack"), + ("21", "Random Rules Attack"), + ("22", "Combipow Passphrase Attack"), ("90", "Download rules from Hashmob.net"), ("91", "Analyze Hashcat Rules"), ("92", "Download wordlists from Hashmob.net"), @@ -3638,7 +3933,10 @@ def get_main_menu_options(): "16": omen_attack, "17": adhoc_mask_crack, "18": markov_brute_force, - "21": combipow_crack, + "19": ngram_attack, + "20": permute_crack, + "21": generate_rules_crack, + "22": combipow_crack, "90": lambda: download_hashmob_rules(rules_dir=rulesDirectory), "91": analyze_rules, "92": download_hashmob_wordlists, diff --git a/tests/test_attacks_behavior.py b/tests/test_attacks_behavior.py index f574997..4096819 100644 --- a/tests/test_attacks_behavior.py +++ b/tests/test_attacks_behavior.py @@ -216,7 +216,7 @@ class TestCombinatorCrack: ctx._resolve_wordlist_path.assert_any_call("/wl/a.txt", ctx.hcatWordlists) ctx._resolve_wordlist_path.assert_any_call("/wl/b.txt", ctx.hcatWordlists) - def test_uses_only_first_two_wordlists(self) -> None: + def test_three_wordlists_in_config_routes_to_combinator3(self) -> None: ctx = _make_ctx() ctx.hcatCombinationWordlist = ["/wl/a.txt", "/wl/b.txt", "/wl/c.txt"] ctx._resolve_wordlist_path.side_effect = lambda wl, _: wl @@ -224,9 +224,10 @@ class TestCombinatorCrack: with patch("builtins.input", return_value=""): combinator_crack(ctx) - call_wordlists = ctx.hcatCombination.call_args[0][2] - assert len(call_wordlists) == 2 - assert "/wl/c.txt" not in call_wordlists + ctx.hcatCombinator3.assert_called_once() + ctx.hcatCombination.assert_not_called() + call_wordlists = ctx.hcatCombinator3.call_args[0][2] + assert len(call_wordlists) == 3 class TestHybridCrack: diff --git a/tests/test_combinator3_combinatorX.py b/tests/test_combinator3_combinatorX.py new file mode 100644 index 0000000..25fd5b5 --- /dev/null +++ b/tests/test_combinator3_combinatorX.py @@ -0,0 +1,154 @@ +from unittest.mock import MagicMock, patch + +from hate_crack.attacks import combinator_crack, combinator_submenu + + +def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"): + ctx = MagicMock() + ctx.hcatHashType = hash_type + ctx.hcatHashFile = hash_file + return ctx + + +class TestCombinatorCrackUnified: + def test_two_wordlists_calls_hcatCombination(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + for name in ["a.txt", "b.txt"]: + (tmp_path / name).write_text("word\n") + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + inputs = ["n", f"{tmp_path}/a.txt", f"{tmp_path}/b.txt", "", ""] + with patch("builtins.input", side_effect=inputs): + combinator_crack(ctx) + ctx.hcatCombination.assert_called_once() + ctx.hcatCombinator3.assert_not_called() + ctx.hcatCombinatorX.assert_not_called() + + def test_three_wordlists_calls_hcatCombinator3(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + for name in ["a.txt", "b.txt", "c.txt"]: + (tmp_path / name).write_text("word\n") + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + inputs = ["n", f"{tmp_path}/a.txt", f"{tmp_path}/b.txt", f"{tmp_path}/c.txt", "", ""] + with patch("builtins.input", side_effect=inputs): + combinator_crack(ctx) + ctx.hcatCombinator3.assert_called_once() + ctx.hcatCombination.assert_not_called() + ctx.hcatCombinatorX.assert_not_called() + + def test_four_wordlists_calls_hcatCombinatorX(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + for name in ["a.txt", "b.txt", "c.txt", "d.txt"]: + (tmp_path / name).write_text("word\n") + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + inputs = [ + "n", + f"{tmp_path}/a.txt", + f"{tmp_path}/b.txt", + f"{tmp_path}/c.txt", + f"{tmp_path}/d.txt", + "", + "", + ] + with patch("builtins.input", side_effect=inputs): + combinator_crack(ctx) + ctx.hcatCombinatorX.assert_called_once() + ctx.hcatCombination.assert_not_called() + ctx.hcatCombinator3.assert_not_called() + + def test_separator_forces_combinatorX_for_two_wordlists(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + for name in ["a.txt", "b.txt"]: + (tmp_path / name).write_text("word\n") + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + inputs = ["n", f"{tmp_path}/a.txt", f"{tmp_path}/b.txt", "", "-"] + with patch("builtins.input", side_effect=inputs): + combinator_crack(ctx) + ctx.hcatCombinatorX.assert_called_once() + ctx.hcatCombination.assert_not_called() + + def test_separator_forces_combinatorX_for_three_wordlists(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + for name in ["a.txt", "b.txt", "c.txt"]: + (tmp_path / name).write_text("word\n") + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + inputs = ["n", f"{tmp_path}/a.txt", f"{tmp_path}/b.txt", f"{tmp_path}/c.txt", "", "-"] + with patch("builtins.input", side_effect=inputs): + combinator_crack(ctx) + ctx.hcatCombinatorX.assert_called_once() + ctx.hcatCombinator3.assert_not_called() + + def test_aborts_with_fewer_than_2_wordlists(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + (tmp_path / "a.txt").write_text("word\n") + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + inputs = ["n", f"{tmp_path}/a.txt", ""] + with patch("builtins.input", side_effect=inputs): + combinator_crack(ctx) + ctx.hcatCombination.assert_not_called() + ctx.hcatCombinator3.assert_not_called() + ctx.hcatCombinatorX.assert_not_called() + + def test_aborts_when_no_wordlists_provided(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + with patch("builtins.input", side_effect=["n", ""]): + combinator_crack(ctx) + ctx.hcatCombination.assert_not_called() + ctx.hcatCombinator3.assert_not_called() + ctx.hcatCombinatorX.assert_not_called() + + def test_no_separator_passes_none_to_combinatorX(self, tmp_path): + ctx = _make_ctx() + ctx.hcatWordlists = str(tmp_path) + for name in ["a.txt", "b.txt", "c.txt", "d.txt"]: + (tmp_path / name).write_text("word\n") + ctx._resolve_wordlist_path.side_effect = lambda p, base: p + inputs = [ + "n", + f"{tmp_path}/a.txt", + f"{tmp_path}/b.txt", + f"{tmp_path}/c.txt", + f"{tmp_path}/d.txt", + "", + "", + ] + with patch("builtins.input", side_effect=inputs): + combinator_crack(ctx) + call_args = ctx.hcatCombinatorX.call_args + positional_sep = call_args[0][3] if len(call_args[0]) >= 4 else None + keyword_sep = call_args[1].get("separator") + assert positional_sep in (None, "") and keyword_sep in (None, "") + + +class TestCombinatorSubmenuUpdated: + def test_submenu_option1_dispatches_to_combinator_crack(self): + ctx = _make_ctx() + with patch("hate_crack.attacks.combinator_crack") as mock_c, patch( + "hate_crack.attacks.interactive_menu", side_effect=["1", "99"] + ): + combinator_submenu(ctx) + mock_c.assert_called_once_with(ctx) + + def test_submenu_has_no_separate_3plus_option(self): + """Verify option 5 (3+) is removed - combinator is now unified under option 1.""" + ctx = _make_ctx() + captured_items = [] + + def capture_menu(items, **kwargs): + captured_items.extend(items) + return "99" + + with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu): + combinator_submenu(ctx) + + keys = [item[0] for item in captured_items] + assert "1" in keys + assert "5" not in keys + assert "6" not in keys diff --git a/tests/test_combinator_wrappers.py b/tests/test_combinator_wrappers.py new file mode 100644 index 0000000..3ace009 --- /dev/null +++ b/tests/test_combinator_wrappers.py @@ -0,0 +1,300 @@ +"""Tests for hcatCombinator3 and hcatCombinatorX hashcat wrapper functions.""" + +from unittest.mock import MagicMock, patch + +import pytest + + +def _make_mock_proc(wait_side_effect=None): + proc = MagicMock() + proc.stdout = MagicMock() + if wait_side_effect is not None: + proc.wait.side_effect = wait_side_effect + else: + proc.wait.return_value = None + proc.pid = 12345 + return proc + + +@pytest.fixture +def main_module(hc_module): + return hc_module._main + + +class TestHcatCombinator3: + def test_calls_combinator3_bin_with_three_files(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(3): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinator3" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinator3("1000", hash_file, wls) + + calls = mock_popen.call_args_list + assert len(calls) == 2 + combinator_cmd = calls[0][0][0] + assert "combinator3" in combinator_cmd[0] + assert wls[0] in combinator_cmd + assert wls[1] in combinator_cmd + assert wls[2] in combinator_cmd + + def test_pipes_stdout_to_hashcat_stdin(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(3): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinator3" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinator3("1000", hash_file, wls) + + calls = mock_popen.call_args_list + hashcat_call_kwargs = calls[1][1] + assert hashcat_call_kwargs.get("stdin") == combinator_proc.stdout + + def test_aborts_with_fewer_than_3_wordlists(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wl1 = str(tmp_path / "w1.txt") + wl2 = str(tmp_path / "w2.txt") + for p in [wl1, wl2]: + open(p, "w").close() + + with patch("hate_crack.main.subprocess.Popen") as mock_popen: + main_module.hcatCombinator3("1000", hash_file, [wl1, wl2]) + + mock_popen.assert_not_called() + + def test_keyboard_interrupt_kills_both_processes(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(3): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc(wait_side_effect=KeyboardInterrupt()) + + def popen_side_effect(cmd, **kwargs): + if "combinator3" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect), + ): + main_module.hcatCombinator3("1000", hash_file, wls) + + hashcat_proc.kill.assert_called_once() + combinator_proc.kill.assert_called_once() + + +class TestHcatCombinatorX: + def test_calls_combinatorX_bin_with_file_flags(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(2): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinatorX" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinatorX("1000", hash_file, wls) + + calls = mock_popen.call_args_list + assert len(calls) == 2 + combinator_cmd = calls[0][0][0] + assert "combinatorX" in combinator_cmd[0] + assert "--file1" in combinator_cmd + assert "--file2" in combinator_cmd + + def test_passes_sepfill_when_separator_given(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(2): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinatorX" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinatorX("1000", hash_file, wls, separator="-") + + combinator_cmd = mock_popen.call_args_list[0][0][0] + assert "--sepFill" in combinator_cmd + sep_idx = combinator_cmd.index("--sepFill") + assert combinator_cmd[sep_idx + 1] == "-" + + def test_no_sepfill_when_separator_is_none(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(2): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinatorX" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinatorX("1000", hash_file, wls, separator=None) + + combinator_cmd = mock_popen.call_args_list[0][0][0] + assert "--sepFill" not in combinator_cmd + + def test_aborts_with_fewer_than_2_wordlists(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wl1 = str(tmp_path / "w1.txt") + open(wl1, "w").close() + + with patch("hate_crack.main.subprocess.Popen") as mock_popen: + main_module.hcatCombinatorX("1000", hash_file, [wl1]) + + mock_popen.assert_not_called() + + def test_supports_up_to_8_wordlists(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(8): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc() + + def popen_side_effect(cmd, **kwargs): + if "combinatorX" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + ): + main_module.hcatCombinatorX("1000", hash_file, wls) + + combinator_cmd = mock_popen.call_args_list[0][0][0] + for i in range(1, 9): + assert f"--file{i}" in combinator_cmd + + def test_keyboard_interrupt_kills_both_processes(self, main_module, tmp_path): + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(2): + p = str(tmp_path / f"w{i}.txt") + open(p, "w").close() + wls.append(p) + + combinator_proc = _make_mock_proc() + hashcat_proc = _make_mock_proc(wait_side_effect=KeyboardInterrupt()) + + def popen_side_effect(cmd, **kwargs): + if "combinatorX" in str(cmd[0]): + return combinator_proc + return hashcat_proc + + with ( + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "hcatWordlists", str(tmp_path)), + patch.object(main_module, "generate_session_id", return_value="sess123"), + patch.object(main_module, "lineCount", return_value=0), + patch("hate_crack.main.subprocess.Popen", side_effect=popen_side_effect), + ): + main_module.hcatCombinatorX("1000", hash_file, wls) + + hashcat_proc.kill.assert_called_once() + combinator_proc.kill.assert_called_once() diff --git a/tests/test_combipow_attack.py b/tests/test_combipow_attack.py index 8700f98..4952ef6 100644 --- a/tests/test_combipow_attack.py +++ b/tests/test_combipow_attack.py @@ -46,16 +46,16 @@ def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"): def test_combipow_crack_in_main_menu(cli): options = cli.get_main_menu_options() - assert "21" in options + assert "22" in options def test_combipow_crack_menu_item_label(): cli = _load_cli() items = cli.get_main_menu_items() keys = [k for k, _ in items] - assert "21" in keys + assert "22" in keys labels = {k: label for k, label in items} - assert "passphrase" in labels["21"].lower() or "combipow" in labels["21"].lower() + assert "passphrase" in labels["22"].lower() or "combipow" in labels["22"].lower() # --- combipow_crack handler tests --- diff --git a/tests/test_hashcat_rules.py b/tests/test_hashcat_rules.py index 1a2636c..ebaa3a2 100644 --- a/tests/test_hashcat_rules.py +++ b/tests/test_hashcat_rules.py @@ -122,6 +122,10 @@ def test_toggle_rule_parses_with_and_without_loopback(tmp_path: Path, capsys): pytest.skip("hashcat not available in PATH") if not _hashcat_sessions_writable(): pytest.skip("hashcat session directory (~/.hashcat/sessions) is not writable") + # Hashcat renames hashcat.induct after each run; recreate so loopback can write. + (Path.home() / ".hashcat" / "sessions" / "hashcat.induct").mkdir( + parents=True, exist_ok=True + ) show_output = os.environ.get("HATE_CRACK_SHOW_HASHCAT_OUTPUT") == "1" show_cmd = ( diff --git a/tests/test_ngram_gzip.py b/tests/test_ngram_gzip.py new file mode 100644 index 0000000..3c43360 --- /dev/null +++ b/tests/test_ngram_gzip.py @@ -0,0 +1,149 @@ +import gzip +import os +from unittest.mock import MagicMock, patch + +from hate_crack.attacks import ngram_attack + + +def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"): + ctx = MagicMock() + ctx.hcatHashType = hash_type + ctx.hcatHashFile = hash_file + return ctx + + +class TestNgramAttack: + def test_calls_hcatNgramX_with_corpus_and_group_size(self, tmp_path): + ctx = _make_ctx() + corpus = tmp_path / "corpus.txt" + corpus.write_text("password\nletmein\n") + ctx.select_file_with_autocomplete.return_value = str(corpus) + + with patch("builtins.input", return_value="3"): + ngram_attack(ctx) + + ctx.hcatNgramX.assert_called_once_with( + ctx.hcatHashType, ctx.hcatHashFile, str(corpus), 3 + ) + + def test_default_group_size_is_3(self, tmp_path): + ctx = _make_ctx() + corpus = tmp_path / "corpus.txt" + corpus.write_text("password\n") + ctx.select_file_with_autocomplete.return_value = str(corpus) + + with patch("builtins.input", return_value=""): + ngram_attack(ctx) + + ctx.hcatNgramX.assert_called_once() + assert ctx.hcatNgramX.call_args[0][3] == 3 + + def test_invalid_group_size_defaults_to_3(self, tmp_path): + ctx = _make_ctx() + corpus = tmp_path / "corpus.txt" + corpus.write_text("password\n") + ctx.select_file_with_autocomplete.return_value = str(corpus) + + with patch("builtins.input", return_value="abc"): + ngram_attack(ctx) + + ctx.hcatNgramX.assert_called_once() + assert ctx.hcatNgramX.call_args[0][3] == 3 + + def test_aborts_when_no_corpus_selected(self): + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = None + + ngram_attack(ctx) + + ctx.hcatNgramX.assert_not_called() + + def test_custom_group_size_passed_through(self, tmp_path): + ctx = _make_ctx() + corpus = tmp_path / "corpus.txt" + corpus.write_text("password\n") + ctx.select_file_with_autocomplete.return_value = str(corpus) + + with patch("builtins.input", return_value="5"): + ngram_attack(ctx) + + assert ctx.hcatNgramX.call_args[0][3] == 5 + + +class TestIsGzipped: + def test_detects_gzip_file(self, tmp_path): + from hate_crack.main import _is_gzipped + + gz_file = tmp_path / "test.txt.gz" + with gzip.open(str(gz_file), "wb") as f: + f.write(b"password\n") + + assert _is_gzipped(str(gz_file)) is True + + def test_plain_file_not_detected_as_gzip(self, tmp_path): + from hate_crack.main import _is_gzipped + + plain = tmp_path / "test.txt" + plain.write_bytes(b"password\n") + + assert _is_gzipped(str(plain)) is False + + def test_missing_file_returns_false(self, tmp_path): + from hate_crack.main import _is_gzipped + + assert _is_gzipped(str(tmp_path / "nonexistent.txt")) is False + + def test_empty_file_returns_false(self, tmp_path): + from hate_crack.main import _is_gzipped + + empty = tmp_path / "empty.txt" + empty.write_bytes(b"") + + assert _is_gzipped(str(empty)) is False + + +class TestWordlistPath: + def test_plain_file_yields_original_path(self, tmp_path): + from hate_crack.main import _wordlist_path + + plain = tmp_path / "words.txt" + plain.write_text("password\n") + + with _wordlist_path(str(plain)) as result: + assert result == str(plain) + + def test_gzip_file_yields_temp_file_with_content(self, tmp_path): + from hate_crack.main import _wordlist_path + + gz_file = tmp_path / "words.txt.gz" + with gzip.open(str(gz_file), "wb") as f: + f.write(b"password\nletmein\n") + + with _wordlist_path(str(gz_file)) as result: + assert result != str(gz_file) + assert os.path.isfile(result) + with open(result, "rb") as f: + assert f.read() == b"password\nletmein\n" + + def test_gzip_temp_file_removed_after_context(self, tmp_path): + from hate_crack.main import _wordlist_path + + gz_file = tmp_path / "words.txt.gz" + with gzip.open(str(gz_file), "wb") as f: + f.write(b"password\n") + + with _wordlist_path(str(gz_file)) as result: + tmp_path_used = result + + assert not os.path.exists(tmp_path_used) + + def test_plain_file_not_deleted_after_context(self, tmp_path): + from hate_crack.main import _wordlist_path + + plain = tmp_path / "words.txt" + plain.write_text("password\n") + + with _wordlist_path(str(plain)) as result: + assert result == str(plain) + + assert plain.exists() diff --git a/tests/test_permute_attack.py b/tests/test_permute_attack.py new file mode 100644 index 0000000..d5bec44 --- /dev/null +++ b/tests/test_permute_attack.py @@ -0,0 +1,67 @@ +import os +from unittest.mock import MagicMock, patch + +import pytest + +from hate_crack.attacks import permute_crack + + +def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"): + ctx = MagicMock() + ctx.hcatHashType = hash_type + ctx.hcatHashFile = hash_file + ctx.hcatWordlists = "/tmp/wordlists" + return ctx + + +class TestPermuteCrack: + def test_calls_hcatPermute_with_valid_wordlist(self, tmp_path): + ctx = _make_ctx() + wl = tmp_path / "target.txt" + wl.write_text("abc\ndef\n") + with patch("builtins.input", return_value=str(wl)): + permute_crack(ctx) + ctx.hcatPermute.assert_called_once_with( + ctx.hcatHashType, ctx.hcatHashFile, str(wl) + ) + + def test_rejects_nonexistent_wordlist_then_accepts_valid(self, tmp_path): + ctx = _make_ctx() + wl = tmp_path / "real.txt" + wl.write_text("test\n") + with patch( + "builtins.input", + side_effect=["/nonexistent/path.txt", str(wl)], + ): + permute_crack(ctx) + ctx.hcatPermute.assert_called_once_with( + ctx.hcatHashType, ctx.hcatHashFile, str(wl) + ) + + def test_rejects_directory_then_accepts_file(self, tmp_path): + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("ab\n") + with patch("builtins.input", side_effect=[str(tmp_path), str(wl)]): + permute_crack(ctx) + ctx.hcatPermute.assert_called_once_with( + ctx.hcatHashType, ctx.hcatHashFile, str(wl) + ) + + def test_warns_about_factorial_scaling(self, tmp_path, capsys): + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("abc\n") + with patch("builtins.input", return_value=str(wl)): + permute_crack(ctx) + captured = capsys.readouterr() + assert "WARNING" in captured.out or "factorial" in captured.out.lower() or "N!" in captured.out + + def test_prints_header(self, tmp_path, capsys): + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("abc\n") + with patch("builtins.input", return_value=str(wl)): + permute_crack(ctx) + captured = capsys.readouterr() + assert "PERMUTATION" in captured.out.upper() diff --git a/tests/test_permute_wrapper.py b/tests/test_permute_wrapper.py new file mode 100644 index 0000000..54865dd --- /dev/null +++ b/tests/test_permute_wrapper.py @@ -0,0 +1,162 @@ +import os +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def main_module(hc_module): + """Return the underlying hate_crack.main module for direct patching.""" + return hc_module._main + + +class TestHcatPermute: + def test_uses_permute_bin(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("abc\n") + hash_file = str(tmp_path / "hashes.txt") + permute_bin_dir = tmp_path / "hashcat-utils" / "bin" + permute_bin_dir.mkdir(parents=True) + permute_bin = permute_bin_dir / "permute.bin" + permute_bin.touch() + + mock_permute_proc = MagicMock() + mock_permute_proc.stdout = MagicMock() + mock_permute_proc.wait.return_value = None + + mock_hashcat_proc = MagicMock() + mock_hashcat_proc.wait.return_value = None + mock_hashcat_proc.pid = 99 + + with patch.object(main_module, "hate_path", str(tmp_path)), \ + patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="sess1"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0, create=True), \ + patch("hate_crack.main.subprocess.Popen") as mock_popen: + mock_popen.side_effect = [mock_permute_proc, mock_hashcat_proc] + main_module.hcatPermute("1000", hash_file, str(wl)) + + assert mock_popen.call_count == 2 + first_call_args = mock_popen.call_args_list[0][0][0] + assert "permute.bin" in str(first_call_args) + + def test_pipes_permute_stdout_to_hashcat_stdin(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("abc\n") + hash_file = str(tmp_path / "hashes.txt") + permute_bin_dir = tmp_path / "hashcat-utils" / "bin" + permute_bin_dir.mkdir(parents=True) + (permute_bin_dir / "permute.bin").touch() + + mock_permute_proc = MagicMock() + mock_permute_proc.stdout = MagicMock() + mock_permute_proc.wait.return_value = None + + mock_hashcat_proc = MagicMock() + mock_hashcat_proc.wait.return_value = None + mock_hashcat_proc.pid = 99 + + with patch.object(main_module, "hate_path", str(tmp_path)), \ + patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="sess1"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0, create=True), \ + patch("hate_crack.main.subprocess.Popen") as mock_popen: + mock_popen.side_effect = [mock_permute_proc, mock_hashcat_proc] + main_module.hcatPermute("1000", hash_file, str(wl)) + + # Second call (hashcat) should use permute_proc.stdout as stdin + second_call_kwargs = mock_popen.call_args_list[1][1] + assert second_call_kwargs.get("stdin") == mock_permute_proc.stdout + + def test_hashcat_cmd_includes_hash_type_and_file(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("abc\n") + hash_file = str(tmp_path / "hashes.txt") + permute_bin_dir = tmp_path / "hashcat-utils" / "bin" + permute_bin_dir.mkdir(parents=True) + (permute_bin_dir / "permute.bin").touch() + + mock_permute_proc = MagicMock() + mock_permute_proc.stdout = MagicMock() + mock_permute_proc.wait.return_value = None + + mock_hashcat_proc = MagicMock() + mock_hashcat_proc.wait.return_value = None + mock_hashcat_proc.pid = 99 + + with patch.object(main_module, "hate_path", str(tmp_path)), \ + patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="sess1"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0, create=True), \ + patch("hate_crack.main.subprocess.Popen") as mock_popen: + mock_popen.side_effect = [mock_permute_proc, mock_hashcat_proc] + main_module.hcatPermute("1000", hash_file, str(wl)) + + hashcat_cmd = mock_popen.call_args_list[1][0][0] + assert "hashcat" in hashcat_cmd + assert "-m" in hashcat_cmd + assert "1000" in hashcat_cmd + assert hash_file in hashcat_cmd + + def test_keyboard_interrupt_kills_both_processes(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("abc\n") + hash_file = str(tmp_path / "hashes.txt") + permute_bin_dir = tmp_path / "hashcat-utils" / "bin" + permute_bin_dir.mkdir(parents=True) + (permute_bin_dir / "permute.bin").touch() + + mock_permute_proc = MagicMock() + mock_permute_proc.stdout = MagicMock() + mock_permute_proc.wait.return_value = None + + mock_hashcat_proc = MagicMock() + mock_hashcat_proc.wait.side_effect = KeyboardInterrupt() + mock_hashcat_proc.pid = 99 + + with patch.object(main_module, "hate_path", str(tmp_path)), \ + patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="sess1"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0, create=True), \ + patch("hate_crack.main.subprocess.Popen") as mock_popen: + mock_popen.side_effect = [mock_permute_proc, mock_hashcat_proc] + main_module.hcatPermute("1000", hash_file, str(wl)) + + mock_hashcat_proc.kill.assert_called_once() + mock_permute_proc.kill.assert_called_once() + + def test_missing_permute_bin_prints_error(self, main_module, tmp_path, capsys): + wl = tmp_path / "words.txt" + wl.write_text("abc\n") + hash_file = str(tmp_path / "hashes.txt") + # No permute.bin created + + with patch.object(main_module, "hate_path", str(tmp_path)): + main_module.hcatPermute("1000", hash_file, str(wl)) + + captured = capsys.readouterr() + assert "permute.bin" in captured.out + + def test_missing_wordlist_prints_error(self, main_module, tmp_path, capsys): + hash_file = str(tmp_path / "hashes.txt") + permute_bin_dir = tmp_path / "hashcat-utils" / "bin" + permute_bin_dir.mkdir(parents=True) + (permute_bin_dir / "permute.bin").touch() + + with patch.object(main_module, "hate_path", str(tmp_path)): + main_module.hcatPermute("1000", hash_file, "/nonexistent/words.txt") + + captured = capsys.readouterr() + assert "not found" in captured.out.lower() or "error" in captured.out.lower() diff --git a/tests/test_random_rules_attack.py b/tests/test_random_rules_attack.py new file mode 100644 index 0000000..d9d7fff --- /dev/null +++ b/tests/test_random_rules_attack.py @@ -0,0 +1,45 @@ +import importlib.util +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def load_cli_module(): + os.environ["HATE_CRACK_SKIP_INIT"] = "1" + for key in list(sys.modules.keys()): + if "hate_crack" in key: + del sys.modules[key] + spec = importlib.util.spec_from_file_location( + "hate_crack_cli", PROJECT_ROOT / "hate_crack.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def cli(): + return load_cli_module() + + +def test_generate_rules_crack_in_main_menu(cli): + options = cli.get_main_menu_options() + assert "20" in options + + +def test_generate_rules_crack_handler_calls_main(cli, tmp_path): + ctx = MagicMock() + ctx.hcatHashType = "1000" + ctx.hcatHashFile = "/tmp/h.txt" + ctx.hcatWordlists = str(tmp_path) + ctx.list_wordlist_files.return_value = [] + wl = tmp_path / "words.txt" + wl.write_text("password\n") + with patch("builtins.input", side_effect=["100", str(wl)]): + cli._attacks.generate_rules_crack(ctx) + ctx.hcatGenerateRules.assert_called_once_with("1000", "/tmp/h.txt", 100, str(wl)) diff --git a/tests/test_random_rules_wrapper.py b/tests/test_random_rules_wrapper.py new file mode 100644 index 0000000..be6894f --- /dev/null +++ b/tests/test_random_rules_wrapper.py @@ -0,0 +1,163 @@ +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def main_module(hc_module): + return hc_module._main + + +def _make_mock_proc(wait_side_effect=None): + proc = MagicMock() + if wait_side_effect is not None: + proc.wait.side_effect = wait_side_effect + else: + proc.wait.return_value = None + proc.pid = 12345 + return proc + + +class TestHcatGenerateRules: + def test_calls_generate_rules_bin(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("test\n") + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + mock_result = MagicMock() + mock_result.stdout = "l\nu\nc\n" + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0), \ + patch("hate_crack.main.subprocess.run", return_value=mock_result) as mock_run, \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc): + main_module.hcatGenerateRules("1000", hash_file, 100, str(wl)) + + run_calls = mock_run.call_args_list + assert any("generate-rules.bin" in str(c) for c in run_calls) + + def test_calls_hashcat_with_rule_flag(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("test\n") + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + mock_result = MagicMock() + mock_result.stdout = "l\nu\n" + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0), \ + patch("hate_crack.main.subprocess.run", return_value=mock_result), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc) as mock_popen: + main_module.hcatGenerateRules("1000", hash_file, 100, str(wl)) + + popen_calls = mock_popen.call_args_list + assert any("-r" in str(c) for c in popen_calls) + + def test_passes_rule_count_to_generate_rules_bin(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("test\n") + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + mock_result = MagicMock() + mock_result.stdout = "l\n" + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0), \ + patch("hate_crack.main.subprocess.run", return_value=mock_result) as mock_run, \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc): + main_module.hcatGenerateRules("1000", hash_file, 999, str(wl)) + + run_calls = mock_run.call_args_list + generate_call = next( + (c for c in run_calls if "generate-rules.bin" in str(c)), None + ) + assert generate_call is not None + cmd_args = generate_call[0][0] + assert "999" in cmd_args + + def test_cleans_up_temp_file(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("test\n") + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + mock_result = MagicMock() + mock_result.stdout = "l\nu\n" + captured_paths = [] + + import os as _os + original_unlink = _os.unlink + + def capturing_unlink(path): + captured_paths.append(path) + original_unlink(path) + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0), \ + patch("hate_crack.main.subprocess.run", return_value=mock_result), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc), \ + patch("hate_crack.main.os.unlink", side_effect=capturing_unlink): + main_module.hcatGenerateRules("1000", hash_file, 50, str(wl)) + + assert any("hate_crack_random_" in p for p in captured_paths), \ + f"Expected temp file cleanup, got: {captured_paths}" + + def test_keyboard_interrupt_kills_process(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("test\n") + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc(wait_side_effect=KeyboardInterrupt()) + mock_result = MagicMock() + mock_result.stdout = "l\n" + + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "lineCount", return_value=0), \ + patch.object(main_module, "hcatHashCracked", 0), \ + patch("hate_crack.main.subprocess.run", return_value=mock_result), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc): + main_module.hcatGenerateRules("1000", hash_file, 10, str(wl)) + + mock_proc.kill.assert_called_once() + + def test_sets_hcatGenerateRulesCount(self, main_module, tmp_path): + wl = tmp_path / "words.txt" + wl.write_text("test\n") + hash_file = str(tmp_path / "hashes.txt") + mock_proc = _make_mock_proc() + mock_result = MagicMock() + mock_result.stdout = "l\nu\n" + + # patch.object won't patch reads of module-level globals; set directly + original_cracked = main_module.hcatHashCracked + main_module.hcatHashCracked = 2 + try: + with patch.object(main_module, "hcatBin", "hashcat"), \ + patch.object(main_module, "hcatTuning", ""), \ + patch.object(main_module, "hcatPotfilePath", ""), \ + patch.object(main_module, "generate_session_id", return_value="test_session"), \ + patch.object(main_module, "lineCount", return_value=5), \ + patch("hate_crack.main.subprocess.run", return_value=mock_result), \ + patch("hate_crack.main.subprocess.Popen", return_value=mock_proc): + main_module.hcatGenerateRules("1000", hash_file, 10, str(wl)) + finally: + main_module.hcatHashCracked = original_cracked + + assert main_module.hcatGenerateRulesCount == 3 # 5 - 2 diff --git a/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index f494f7c..4815330 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -26,7 +26,10 @@ MENU_OPTION_TEST_CASES = [ ("16", CLI_MODULE._attacks, "omen_attack", "omen"), ("17", CLI_MODULE._attacks, "adhoc_mask_crack", "adhoc-mask"), ("18", CLI_MODULE._attacks, "markov_brute_force", "markov-brute"), - ("21", CLI_MODULE._attacks, "combipow_crack", "combipow"), + ("19", CLI_MODULE._attacks, "ngram_attack", "ngram"), + ("20", CLI_MODULE._attacks, "permute_crack", "permute"), + ("21", CLI_MODULE._attacks, "generate_rules_crack", "random-rules"), + ("22", CLI_MODULE._attacks, "combipow_crack", "combipow"), ("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"), ("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"), ("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"), diff --git a/wordlist_optimizer.py b/wordlist_optimizer.py index b231973..e32d207 100644 --- a/wordlist_optimizer.py +++ b/wordlist_optimizer.py @@ -40,7 +40,7 @@ def main(): # Resolve binary paths relative to script location script_dir = os.path.dirname(os.path.realpath(__file__)) - ext = ".app" if sys.platform == "darwin" else ".bin" + ext = ".bin" if sys.platform == "darwin" else ".bin" splitlen_bin = os.path.join(script_dir, "hashcat-utils", "bin", f"splitlen{ext}") rli_bin = os.path.join(script_dir, "hashcat-utils", "bin", f"rli{ext}")