diff --git a/CHANGELOG.md b/CHANGELOG.md index de0eccc..4a076a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Dates are omitted for releases predating this file; see the git tags for exact timing. +## [2.10.11] - 2026-07-23 + +### Changed + +- **Shard Wordlist (Wordlist Tools option 7) now produces all shards in a single run.** + Instead of prompting for a modulus + offset and emitting one file per invocation, it + prompts for an output *base path* and a shard count (N), then writes all N interleaved + parts named with zero-padded part numbers (`base.001`…`base.00N`). This matches the + intended distributed-cracking workflow — split once, copy one part per node — without + re-running the tool for each offset. README usage docs updated accordingly. + ## [2.10.10] - 2026-07-21 ### Security diff --git a/README.md b/README.md index 0c0d322..f19da8e 100644 --- a/README.md +++ b/README.md @@ -505,7 +505,9 @@ The Wordlist Tools submenu provides 7 wordlist preprocessing utilities backed by **Character class mask bits** (used by options 2 and 3): `1`=lowercase, `2`=uppercase, `4`=digit, `8`=symbol, `16`=other. Add values together: `7` = lowercase+uppercase+digit. -**Sharding example**: to split a wordlist across 4 nodes, run option 7 with mod=4 and offset=0 on node 1, offset=1 on node 2, etc. +**How sharding is meant to be used**: sharding splits one wordlist into N equal, non-overlapping parts so the work can be spread across multiple machines or GPUs. Each part is *interleaved* (every N-th line), so every shard is a representative sample of the whole list rather than a contiguous front/back chunk — no single node is stuck cracking only the low-probability tail. + +Run option 7 once, give it an input wordlist, an output base path, and a shard count (N). It writes all N parts in a single pass, named with zero-padded part numbers (`base.001`, `base.002`, … up to `base.00N`). Copy one part to each node and point that node's hashcat run at it. On a single-GPU system sharding gives no speedup, but a single part is still a fast, representative sample for a quick triage pass before committing to the full list. #### Automatic Update Checks @@ -896,7 +898,7 @@ A submenu of wordlist preprocessing utilities using hashcat-utils binaries. All | 4 | Extract Substring | Cut bytes from each word at a given offset and optional length (`cutb.bin`) | | 5 | Split by Length | Create per-length files in an output directory (`splitlen.bin`) | | 6 | Subtract Wordlist | Remove lines from a wordlist that appear in one or more remove files. Mode 1 uses `rli2.bin` (single file); mode 2 uses `rli.bin` (multiple files) | -| 7 | Shard Wordlist | Extract every mod-th line at a given offset to create equal-sized shards (`gate.bin`) | +| 7 | Shard Wordlist | Split a wordlist into N equal, interleaved parts in one run, written as `base.001`…`base.00N` for distributed cracking (`gate.bin`) | All binaries are in `hate_crack/hashcat-utils/bin/`. diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 986bb2f..5dee0f4 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -1173,29 +1173,35 @@ def wordlist_subtract_words(ctx: Any) -> None: def wordlist_shard(ctx: Any) -> None: - """Prompt for input/output paths, modulus, and offset, then shard the wordlist.""" + """Prompt for input/output base path and shard count, then write all N part files.""" infile = ctx.select_file_with_autocomplete( "\n[*] Enter path to input wordlist", base_dir=ctx.hcatWordlists ).strip() if not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = ctx.select_file_with_autocomplete("[*] Enter path to output wordlist").strip() - if not outfile: + outbase = ctx.select_file_with_autocomplete( + "[*] Enter output base path (part numbers are appended, e.g. wl.001)" + ).strip() + if not outbase: print("[!] Output path cannot be empty.") return - mod = int(input("[*] Modulus (shard count, e.g. 4 for 4 shards): ").strip() or "0") + mod = int(input("[*] Shard count (e.g. 4 to split into 4 parts): ").strip() or "0") if mod < 2: - print("[!] Modulus must be at least 2.") + print("[!] Shard count must be at least 2.") return - offset = int(input("[*] Offset (shard index, 0-based, e.g. 0 for first shard): ").strip() or "0") - if offset >= mod: - print(f"[!] Offset must be less than modulus ({mod}).") - return - if ctx.wordlist_gate(infile, outfile, mod, offset): - print(f"\n[*] Shard written to: {outfile}") - else: - print("[!] Shard failed.") + width = max(3, len(str(mod))) + written = [] + for offset in range(mod): + outfile = f"{outbase}.{offset + 1:0{width}d}" + if ctx.wordlist_gate(infile, outfile, mod, offset): + written.append(outfile) + else: + print(f"[!] Shard failed at part {offset + 1}: {outfile}") + return + print(f"\n[*] Wrote {len(written)} shard(s):") + for path in written: + print(f" {path}") def wordlist_optimize(ctx: Any) -> None: diff --git a/tests/test_wordlist_tools.py b/tests/test_wordlist_tools.py index 86a7508..32ff284 100644 --- a/tests/test_wordlist_tools.py +++ b/tests/test_wordlist_tools.py @@ -213,25 +213,43 @@ class TestWordlistSubtractWords: class TestWordlistShard: - def test_calls_wordlist_gate_with_correct_args(self, tmp_path): + def test_writes_all_shards_with_part_numbers(self, tmp_path): ctx = _make_ctx() infile = tmp_path / "in.txt" infile.write_text("word1\nword2\nword3\n") - outfile = tmp_path / "shard.txt" - ctx.select_file_with_autocomplete.side_effect = [str(infile), str(outfile)] - with patch("builtins.input", side_effect=["3", "0"]): + outbase = tmp_path / "shard.txt" + ctx.select_file_with_autocomplete.side_effect = [str(infile), str(outbase)] + with patch("builtins.input", side_effect=["3"]): wordlist_shard(ctx) - ctx.wordlist_gate.assert_called_once_with(str(infile), str(outfile), 3, 0) + assert ctx.wordlist_gate.call_count == 3 + calls = [c.args for c in ctx.wordlist_gate.call_args_list] + assert calls == [ + (str(infile), f"{outbase}.001", 3, 0), + (str(infile), f"{outbase}.002", 3, 1), + (str(infile), f"{outbase}.003", 3, 2), + ] - def test_rejects_offset_gte_mod(self, tmp_path): + def test_pad_width_grows_with_shard_count(self, tmp_path): ctx = _make_ctx() infile = tmp_path / "in.txt" infile.write_text("word1\n") - outfile = tmp_path / "shard.txt" - ctx.select_file_with_autocomplete.side_effect = [str(infile), str(outfile)] - with patch("builtins.input", side_effect=["3", "3"]): + outbase = tmp_path / "shard.txt" + ctx.select_file_with_autocomplete.side_effect = [str(infile), str(outbase)] + with patch("builtins.input", side_effect=["1000"]): wordlist_shard(ctx) - ctx.wordlist_gate.assert_not_called() + first = ctx.wordlist_gate.call_args_list[0].args[1] + assert first == f"{outbase}.0001" + + def test_stops_on_gate_failure(self, tmp_path): + ctx = _make_ctx() + ctx.wordlist_gate.side_effect = [True, False, True] + infile = tmp_path / "in.txt" + infile.write_text("word1\n") + outbase = tmp_path / "shard.txt" + ctx.select_file_with_autocomplete.side_effect = [str(infile), str(outbase)] + with patch("builtins.input", side_effect=["3"]): + wordlist_shard(ctx) + assert ctx.wordlist_gate.call_count == 2 def test_rejects_nonexistent_infile(self, tmp_path): ctx = _make_ctx() @@ -243,9 +261,9 @@ class TestWordlistShard: ctx = _make_ctx() infile = tmp_path / "in.txt" infile.write_text("word1\n") - outfile = tmp_path / "shard.txt" - ctx.select_file_with_autocomplete.side_effect = [str(infile), str(outfile)] - with patch("builtins.input", side_effect=["1", "0"]): + outbase = tmp_path / "shard.txt" + ctx.select_file_with_autocomplete.side_effect = [str(infile), str(outbase)] + with patch("builtins.input", side_effect=["1"]): wordlist_shard(ctx) ctx.wordlist_gate.assert_not_called()