hashsmith-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1014 @@
1
+ import argparse
2
+ import os
3
+ import time
4
+ from typing import Optional
5
+
6
+ from rich.console import Console
7
+ from rich.prompt import IntPrompt, Prompt
8
+ from rich.progress import (
9
+ BarColumn,
10
+ MofNCompleteColumn,
11
+ Progress,
12
+ SpinnerColumn,
13
+ TaskProgressColumn,
14
+ TextColumn,
15
+ TimeElapsedColumn,
16
+ TimeRemainingColumn,
17
+ )
18
+ from rich.text import Text
19
+
20
+ from .algorithms.cracking import brute_force, dictionary_attack, format_rate
21
+ from .algorithms.decoding import (
22
+ decode_base64,
23
+ decode_base32,
24
+ decode_base85,
25
+ decode_base64url,
26
+ decode_base58,
27
+ decode_binary,
28
+ decode_caesar,
29
+ decode_decimal,
30
+ decode_hex,
31
+ decode_octal,
32
+ decode_morse_code,
33
+ decode_rot13,
34
+ decode_url,
35
+ decode_vigenere,
36
+ decode_xor,
37
+ decode_atbash,
38
+ decode_baconian,
39
+ decode_leet_speak,
40
+ decode_reverse,
41
+ decode_brainfuck,
42
+ decode_rail_fence,
43
+ decode_polybius,
44
+ decode_unicode_escaped,
45
+ )
46
+ from .algorithms.encoding import (
47
+ encode_base64,
48
+ encode_base32,
49
+ encode_base85,
50
+ encode_base64url,
51
+ encode_base58,
52
+ encode_binary,
53
+ encode_caesar,
54
+ encode_decimal,
55
+ encode_hex,
56
+ encode_octal,
57
+ encode_morse_code,
58
+ encode_rot13,
59
+ encode_url,
60
+ encode_vigenere,
61
+ encode_xor,
62
+ encode_atbash,
63
+ encode_baconian,
64
+ encode_leet_speak,
65
+ encode_reverse,
66
+ encode_brainfuck,
67
+ encode_rail_fence,
68
+ encode_polybius,
69
+ encode_unicode_escaped,
70
+ )
71
+ from .algorithms.hashing import hash_text
72
+ from .utils.banner import render_banner
73
+ from .utils.clipboard import copy_to_clipboard
74
+ from .utils.identify import detect_encoding_types, detect_hash_probabilities
75
+ from .utils.io import read_text_from_file, resolve_input, write_text_to_file
76
+ from .utils.wordlist import iter_wordlist
77
+ from .utils.hashdetect import detect_hash_types
78
+ from pathlib import Path
79
+
80
+
81
+ THEMES = {
82
+ "cyan": "cyan",
83
+ "green": "green",
84
+ "magenta": "magenta",
85
+ "blue": "blue",
86
+ "yellow": "yellow",
87
+ "red": "red",
88
+ "white": "white",
89
+ }
90
+
91
+
92
+ def build_parser() -> argparse.ArgumentParser:
93
+ parser = argparse.ArgumentParser(
94
+ prog="hashsmith",
95
+ description="Hashsmith CLI for encoding, decoding, hashing, and cracking.",
96
+ epilog=(
97
+ "Examples:\n"
98
+ " hashsmith encode -t base64 -i \"hello\"\n"
99
+ " hashsmith identify -i \"aGVsbG8=\"\n"
100
+ " hashsmith -id -i \"aGVsbG8=\"\n"
101
+ ),
102
+ formatter_class=argparse.RawDescriptionHelpFormatter,
103
+ )
104
+ parser.add_argument("-N", "--no-banner", action="store_true", help="Disable banner")
105
+ parser.add_argument("-T", "--theme", choices=list(THEMES.keys()), default="cyan", help="Accent color")
106
+ parser.add_argument("-A", "--help-all", action="store_true", help="Show help for all commands")
107
+ parser.add_argument("-id", "--identify", action="store_true", help="Shortcut for identify command")
108
+
109
+ main_input_group = parser.add_argument_group("Input Options")
110
+ main_input_group.add_argument("-i", "--text", help="Text input")
111
+ main_input_group.add_argument("-f", "--file", help="Read input from file")
112
+
113
+ main_output_group = parser.add_argument_group("Output Options")
114
+ main_output_group.add_argument("-o", "--out", help="Write output to file")
115
+ main_output_group.add_argument("-c", "--copy", action="store_true", help="Copy output to clipboard")
116
+
117
+ subparsers = parser.add_subparsers(dest="command")
118
+ subparser_map: dict[str, argparse.ArgumentParser] = {}
119
+
120
+ input_parent = argparse.ArgumentParser(add_help=False)
121
+ input_group = input_parent.add_argument_group("Input Options")
122
+ input_group.add_argument("-i", "--text", help="Text input")
123
+ input_group.add_argument("-f", "--file", help="Read input from file")
124
+
125
+ output_parent = argparse.ArgumentParser(add_help=False)
126
+ output_group = output_parent.add_argument_group("Output Options")
127
+ output_group.add_argument("-o", "--out", help="Write output to file")
128
+ output_group.add_argument("-c", "--copy", action="store_true", help="Copy output to clipboard")
129
+
130
+ encode_decode_parent = argparse.ArgumentParser(add_help=False)
131
+ encode_decode_group = encode_decode_parent.add_argument_group("Algorithm Parameters")
132
+ encode_decode_group.add_argument(
133
+ "-t",
134
+ "--type",
135
+ required=True,
136
+ choices=[
137
+ "base64",
138
+ "base64url",
139
+ "base32",
140
+ "base85",
141
+ "base58",
142
+ "hex",
143
+ "binary",
144
+ "decimal",
145
+ "octal",
146
+ "morse",
147
+ "url",
148
+ "caesar",
149
+ "rot13",
150
+ "vigenere",
151
+ "xor",
152
+ "atbash",
153
+ "baconian",
154
+ "leet",
155
+ "reverse",
156
+ "brainf*ck",
157
+ "railfence",
158
+ "polybius",
159
+ "unicode",
160
+ ],
161
+ )
162
+ encode_decode_group.add_argument("-s", "--shift", type=int, default=3, help="Shift for Caesar")
163
+ encode_decode_group.add_argument("-k", "--key", help="Key for Vigenere/XOR")
164
+ encode_decode_group.add_argument("-r", "--rails", type=int, default=2, help="Rails for Rail Fence")
165
+
166
+ crack_input_parent = argparse.ArgumentParser(add_help=False)
167
+ crack_input_group = crack_input_parent.add_argument_group("Input Options")
168
+ crack_input_group.add_argument("-H", "--hash", required=True, dest="target_hash")
169
+ crack_input_group.add_argument("-w", "--wordlist", help="Wordlist path for dictionary attack")
170
+
171
+ identify_parser = subparsers.add_parser(
172
+ "identify",
173
+ help="Identify encoding and hash types",
174
+ parents=[input_parent, output_parent],
175
+ formatter_class=argparse.RawDescriptionHelpFormatter,
176
+ epilog=(
177
+ "Examples:\n"
178
+ " hashsmith identify -i \"aGVsbG8=\"\n"
179
+ " hashsmith identify -i 5f4dcc3b5aa765d61d8327deb882cf99\n"
180
+ " hashsmith identify -f data.txt -o report.txt\n"
181
+ ),
182
+ )
183
+ subparser_map["identify"] = identify_parser
184
+
185
+ encode_parser = subparsers.add_parser(
186
+ "encode",
187
+ help="Encode text",
188
+ parents=[input_parent, output_parent, encode_decode_parent],
189
+ formatter_class=argparse.RawDescriptionHelpFormatter,
190
+ epilog=(
191
+ "Examples:\n"
192
+ " hashsmith encode -t base64 -i \"hello\"\n"
193
+ " hashsmith encode -t caesar -s 5 -f input.txt -o output.txt\n"
194
+ " hashsmith encode -t hex -i \"hello\" -c\n"
195
+ ),
196
+ )
197
+ subparser_map["encode"] = encode_parser
198
+
199
+ decode_parser = subparsers.add_parser(
200
+ "decode",
201
+ help="Decode text",
202
+ parents=[input_parent, output_parent, encode_decode_parent],
203
+ formatter_class=argparse.RawDescriptionHelpFormatter,
204
+ epilog=(
205
+ "Examples:\n"
206
+ " hashsmith decode -t base64 -i \"aGVsbG8=\"\n"
207
+ " hashsmith decode -t base64 -f data.txt -o result.txt\n"
208
+ " hashsmith decode -t hex -i \"68656c6c6f\" -c\n"
209
+ ),
210
+ )
211
+ subparser_map["decode"] = decode_parser
212
+
213
+ hash_parser = subparsers.add_parser(
214
+ "hash",
215
+ help="Hash text",
216
+ parents=[input_parent, output_parent],
217
+ formatter_class=argparse.RawDescriptionHelpFormatter,
218
+ epilog=(
219
+ "Examples:\n"
220
+ " hashsmith hash -t sha256 -i \"admin\" -c\n"
221
+ " hashsmith hash -t md5 -i \"secret\" -s pepper -S suffix\n"
222
+ " hashsmith hash -t sha1 -f input.txt -o hashes.txt\n"
223
+ ),
224
+ )
225
+ subparser_map["hash"] = hash_parser
226
+ hash_params = hash_parser.add_argument_group("Algorithm Parameters")
227
+ hash_output_format = hash_parser.add_argument_group("Output Format")
228
+
229
+ hash_params.add_argument("-t", "--type", required=True, choices=[
230
+ "md5", "md4", "sha1", "sha224", "sha256", "sha384", "sha512", "sha3_224", "sha3_256", "sha3_512",
231
+ "blake2b", "blake2s", "ntlm", "mysql323", "mysql41", "bcrypt",
232
+ "argon2", "scrypt", "mssql2000", "mssql2005", "mssql2012", "postgres"
233
+ ])
234
+ hash_params.add_argument("-s", "--salt", default="", help="Salt value")
235
+ hash_params.add_argument("-S", "--salt-mode", default="prefix", choices=["prefix", "suffix"])
236
+ hash_output_format.add_argument("-e", "--out-encoding", default="hex", choices=["hex", "base58"], help="Output encoding for hex hashes")
237
+
238
+ crack_parser = subparsers.add_parser(
239
+ "crack",
240
+ help="Crack hash",
241
+ parents=[crack_input_parent, output_parent],
242
+ formatter_class=argparse.RawDescriptionHelpFormatter,
243
+ epilog=(
244
+ "Examples:\n"
245
+ " hashsmith crack -t md5 -H 5f4dcc3b5aa765d61d8327deb882cf99 -M dict -w wordlists/common.txt\n"
246
+ " hashsmith crack -t sha1 -H 2aae6c35c94fcfb415dbe95f408b9ce91ee846ed -M brute -n 1 -x 4\n"
247
+ " hashsmith crack -t md5 -H 5f4dcc3b5aa765d61d8327deb882cf99 -M dict -w wordlists/common.txt -c\n"
248
+ ),
249
+ )
250
+ subparser_map["crack"] = crack_parser
251
+ crack_params = crack_parser.add_argument_group("Algorithm Parameters")
252
+
253
+ crack_params.add_argument("-t", "--type", required=True, choices=[
254
+ "auto", "md5", "md4", "sha1", "sha224", "sha256", "sha384", "sha512", "sha3_224", "sha3_256", "sha3_512",
255
+ "blake2b", "blake2s", "ntlm", "mysql323", "mysql41", "bcrypt",
256
+ "argon2", "scrypt", "mssql2000", "mssql2005", "mssql2012", "postgres"
257
+ ])
258
+ crack_params.add_argument("-M", "--mode", required=True, choices=["dict", "brute"])
259
+ crack_params.add_argument("-C", "--charset", default="abcdefghijklmnopqrstuvwxyz0123456789")
260
+ crack_params.add_argument("-n", "--min-len", type=int, default=1)
261
+ crack_params.add_argument("-x", "--max-len", type=int, default=4)
262
+ crack_params.add_argument("-s", "--salt", default="")
263
+ crack_params.add_argument("-S", "--salt-mode", default="prefix", choices=["prefix", "suffix"])
264
+ crack_params.add_argument("-p", "--workers", type=int, default=0, help="Parallel workers for dictionary attack (0=auto)")
265
+
266
+ interactive_parser = subparsers.add_parser(
267
+ "interactive",
268
+ help="Guided interactive mode",
269
+ formatter_class=argparse.RawDescriptionHelpFormatter,
270
+ epilog=(
271
+ "Examples:\n"
272
+ " hashsmith interactive\n"
273
+ ),
274
+ )
275
+ subparser_map["interactive"] = interactive_parser
276
+
277
+ parser.set_defaults(_subparser_map=subparser_map)
278
+
279
+ return parser
280
+
281
+
282
+ def handle_encode(args: argparse.Namespace, console: Console) -> str:
283
+ text = resolve_input(args.text, args.file)
284
+ if args.type in {"vigenere", "xor"} and not args.key:
285
+ raise ValueError("This algorithm requires --key")
286
+ if args.type == "railfence" and args.rails < 2:
287
+ raise ValueError("Rails must be >= 2")
288
+ if args.type == "base64":
289
+ return encode_base64(text)
290
+ if args.type == "base64url":
291
+ return encode_base64url(text)
292
+ if args.type == "base32":
293
+ return encode_base32(text)
294
+ if args.type == "base85":
295
+ return encode_base85(text)
296
+ if args.type == "base58":
297
+ return encode_base58(text)
298
+ if args.type == "hex":
299
+ return encode_hex(text)
300
+ if args.type == "binary":
301
+ return encode_binary(text)
302
+ if args.type == "decimal":
303
+ return encode_decimal(text)
304
+ if args.type == "octal":
305
+ return encode_octal(text)
306
+ if args.type == "morse":
307
+ return encode_morse_code(text)
308
+ if args.type == "url":
309
+ return encode_url(text)
310
+ if args.type == "caesar":
311
+ return encode_caesar(text, args.shift)
312
+ if args.type == "rot13":
313
+ return encode_rot13(text)
314
+ if args.type == "vigenere":
315
+ return encode_vigenere(text, args.key)
316
+ if args.type == "xor":
317
+ return encode_xor(text, args.key)
318
+ if args.type == "atbash":
319
+ return encode_atbash(text)
320
+ if args.type == "baconian":
321
+ return encode_baconian(text)
322
+ if args.type == "leet":
323
+ return encode_leet_speak(text)
324
+ if args.type == "reverse":
325
+ return encode_reverse(text)
326
+ if args.type == "brainf*ck":
327
+ return encode_brainfuck(text)
328
+ if args.type == "railfence":
329
+ return encode_rail_fence(text, args.rails)
330
+ if args.type == "polybius":
331
+ return encode_polybius(text)
332
+ if args.type == "unicode":
333
+ return encode_unicode_escaped(text)
334
+ raise ValueError("Unsupported encode type")
335
+
336
+
337
+ def handle_decode(args: argparse.Namespace, console: Console) -> str:
338
+ text = resolve_input(args.text, args.file)
339
+ if args.type in {"vigenere", "xor"} and not args.key:
340
+ raise ValueError("This algorithm requires --key")
341
+ if args.type == "railfence" and args.rails < 2:
342
+ raise ValueError("Rails must be >= 2")
343
+ if args.type == "base64":
344
+ return decode_base64(text)
345
+ if args.type == "base64url":
346
+ return decode_base64url(text)
347
+ if args.type == "base32":
348
+ return decode_base32(text)
349
+ if args.type == "base85":
350
+ return decode_base85(text)
351
+ if args.type == "base58":
352
+ return decode_base58(text)
353
+ if args.type == "hex":
354
+ return decode_hex(text)
355
+ if args.type == "binary":
356
+ return decode_binary(text)
357
+ if args.type == "decimal":
358
+ return decode_decimal(text)
359
+ if args.type == "octal":
360
+ return decode_octal(text)
361
+ if args.type == "morse":
362
+ return decode_morse_code(text)
363
+ if args.type == "url":
364
+ return decode_url(text)
365
+ if args.type == "caesar":
366
+ return decode_caesar(text, args.shift)
367
+ if args.type == "rot13":
368
+ return decode_rot13(text)
369
+ if args.type == "vigenere":
370
+ return decode_vigenere(text, args.key)
371
+ if args.type == "xor":
372
+ return decode_xor(text, args.key)
373
+ if args.type == "atbash":
374
+ return decode_atbash(text)
375
+ if args.type == "baconian":
376
+ return decode_baconian(text)
377
+ if args.type == "leet":
378
+ return decode_leet_speak(text)
379
+ if args.type == "reverse":
380
+ return decode_reverse(text)
381
+ if args.type == "brainf*ck":
382
+ return decode_brainfuck(text)
383
+ if args.type == "railfence":
384
+ return decode_rail_fence(text, args.rails)
385
+ if args.type == "polybius":
386
+ return decode_polybius(text)
387
+ if args.type == "unicode":
388
+ return decode_unicode_escaped(text)
389
+ raise ValueError("Unsupported decode type")
390
+
391
+
392
+ def handle_hash(args: argparse.Namespace, console: Console) -> str:
393
+ text = resolve_input(args.text, args.file)
394
+ result = hash_text(text, args.type, args.salt, args.salt_mode)
395
+ out_encoding = getattr(args, "out_encoding", "hex")
396
+ if out_encoding == "base58":
397
+ hex_value = result[2:] if result.startswith("0x") else result
398
+ if not is_hex_string(hex_value):
399
+ raise ValueError("Base58 output is only supported for hex hashes")
400
+ result = encode_base58_bytes(bytes.fromhex(hex_value))
401
+ return result
402
+
403
+
404
+ def handle_identify(args: argparse.Namespace, console: Console) -> str:
405
+ text = resolve_input(args.text, args.file)
406
+ encodings = detect_encoding_types(text)
407
+ hash_probs = detect_hash_probabilities(text, top=3)
408
+
409
+ if encodings and not (encodings == ["hex"] and hash_probs):
410
+ return "\n".join(f"{item} encoded text" for item in encodings)
411
+
412
+ if hash_probs:
413
+ return "\n".join(f"{pct}% {name}" for name, pct in hash_probs)
414
+
415
+ return "Probably raw text"
416
+
417
+
418
+ def is_hex_string(value: str) -> bool:
419
+ if not value:
420
+ return False
421
+ value = value.strip()
422
+ return all(ch in "0123456789abcdefABCDEF" for ch in value)
423
+
424
+
425
+ def encode_base58_bytes(data: bytes) -> str:
426
+ alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
427
+ if not data:
428
+ return alphabet[0]
429
+ num = int.from_bytes(data, "big")
430
+ enc = []
431
+ while num > 0:
432
+ num, rem = divmod(num, 58)
433
+ enc.append(alphabet[rem])
434
+ pad = 0
435
+ for b in data:
436
+ if b == 0:
437
+ pad += 1
438
+ else:
439
+ break
440
+ return "1" * pad + "".join(reversed(enc))
441
+
442
+
443
+ def count_wordlist_entries(path: str) -> Optional[int]:
444
+ try:
445
+ count = 0
446
+ with Path(path).expanduser().resolve().open("r", encoding="utf-8", errors="ignore") as handle:
447
+ for line in handle:
448
+ if line.strip():
449
+ count += 1
450
+ return count
451
+ except Exception:
452
+ return None
453
+
454
+
455
+ def handle_crack(args: argparse.Namespace, console: Console, accent: str = "cyan") -> int:
456
+ def build_progress() -> Progress:
457
+ accent_color = accent or "cyan"
458
+ return Progress(
459
+ SpinnerColumn(),
460
+ TextColumn(f"[bold {accent_color}]{{task.description}}"),
461
+ BarColumn(bar_width=None, style=accent_color, complete_style=accent_color),
462
+ TaskProgressColumn(),
463
+ MofNCompleteColumn(),
464
+ TextColumn(f"[bold {accent_color}]•"),
465
+ TimeRemainingColumn(),
466
+ TextColumn(f"[bold {accent_color}]•"),
467
+ TextColumn(f"[bold {accent_color}]{{task.fields[speed]}} H/s"),
468
+ console=console,
469
+ )
470
+
471
+ if args.mode == "dict":
472
+ if args.workers < 1:
473
+ args.workers = os.cpu_count() or 1
474
+ if args.type == "bcrypt" and args.workers > 1:
475
+ console.print("[yellow]bcrypt is CPU-expensive; multi-processing may not scale well.[/yellow]")
476
+ if not args.wordlist:
477
+ console.print("[red]--wordlist is required for dict mode[/red]")
478
+ return 2
479
+ total = count_wordlist_entries(args.wordlist)
480
+ progress = build_progress()
481
+ task_id = progress.add_task("Cracking", total=total, speed="0")
482
+
483
+ attempts = 0
484
+ last_render = 0
485
+ update_every = 1000
486
+ start = time.perf_counter()
487
+
488
+ def progress_callback(delta: int) -> None:
489
+ nonlocal attempts, last_render
490
+ attempts += delta
491
+ if attempts - last_render < update_every:
492
+ return
493
+ elapsed = max(time.perf_counter() - start, 1e-6)
494
+ speed = f"{attempts / elapsed:,.2f}"
495
+ progress.update(task_id, advance=attempts - last_render, speed=speed)
496
+ last_render = attempts
497
+
498
+ try:
499
+ with progress:
500
+ try:
501
+ result = dictionary_attack(
502
+ args.target_hash,
503
+ args.type,
504
+ iter_wordlist(args.wordlist),
505
+ args.salt,
506
+ args.salt_mode,
507
+ workers=args.workers,
508
+ progress_callback=progress_callback,
509
+ )
510
+ finally:
511
+ elapsed = max(time.perf_counter() - start, 1e-6)
512
+ speed = f"{attempts / elapsed:,.2f}"
513
+ if total is not None:
514
+ progress.update(task_id, completed=total, speed=speed)
515
+ else:
516
+ progress.update(task_id, advance=max(attempts - last_render, 0), speed=speed)
517
+ progress.refresh()
518
+ except KeyboardInterrupt:
519
+ progress.stop()
520
+ raise
521
+ else:
522
+ total = 0
523
+ charset_len = len(args.charset)
524
+ for length in range(args.min_len, args.max_len + 1):
525
+ total += charset_len ** length
526
+ progress = build_progress()
527
+ task_id = progress.add_task("Cracking", total=total, speed="0")
528
+
529
+ attempts = 0
530
+ last_render = 0
531
+ update_every = 1000
532
+ start = time.perf_counter()
533
+
534
+ def progress_callback(delta: int) -> None:
535
+ nonlocal attempts, last_render
536
+ attempts += delta
537
+ if attempts - last_render < update_every:
538
+ return
539
+ elapsed = max(time.perf_counter() - start, 1e-6)
540
+ speed = f"{attempts / elapsed:,.2f}"
541
+ progress.update(task_id, advance=attempts - last_render, speed=speed)
542
+ last_render = attempts
543
+
544
+ try:
545
+ with progress:
546
+ try:
547
+ result = brute_force(
548
+ args.target_hash,
549
+ args.type,
550
+ charset=args.charset,
551
+ min_len=args.min_len,
552
+ max_len=args.max_len,
553
+ salt=args.salt,
554
+ salt_mode=args.salt_mode,
555
+ progress_callback=progress_callback,
556
+ )
557
+ finally:
558
+ elapsed = max(time.perf_counter() - start, 1e-6)
559
+ speed = f"{attempts / elapsed:,.2f}"
560
+ progress.update(task_id, completed=total, speed=speed)
561
+ progress.refresh()
562
+ except KeyboardInterrupt:
563
+ progress.stop()
564
+ raise
565
+
566
+ if result.found:
567
+ console.print(f"[green]Found:[/green] {result.password}")
568
+ if getattr(args, "copy", False):
569
+ if copy_to_clipboard(result.password or ""):
570
+ console.print("[green]Copied to clipboard[/green]")
571
+ else:
572
+ console.print("[yellow]Unable to copy to clipboard[/yellow]")
573
+ if getattr(args, "out", None):
574
+ write_text_to_file(args.out, result.password or "")
575
+ console.print(f"[green]Saved to {args.out}[/green]")
576
+ else:
577
+ console.print("[yellow]Not found[/yellow]")
578
+
579
+ console.print(
580
+ Text(
581
+ f"Attempts: {result.attempts} | Elapsed: {result.elapsed:.2f}s | Rate: {format_rate(result.rate)}",
582
+ style=accent,
583
+ )
584
+ )
585
+ return 0
586
+
587
+
588
+ def output_result(result: str, out: Optional[str], console: Console, copy: bool = False) -> None:
589
+ if out:
590
+ write_text_to_file(out, result)
591
+ console.print(f"[green]Saved to {out}[/green]")
592
+ else:
593
+ console.file.write(f"{result}\n")
594
+ console.file.flush()
595
+ if copy:
596
+ if copy_to_clipboard(result):
597
+ console.print("[green]Copied to clipboard[/green]")
598
+ else:
599
+ console.print("[yellow]Unable to copy to clipboard[/yellow]")
600
+
601
+
602
+ def output_identify_result(
603
+ result: str,
604
+ out: Optional[str],
605
+ console: Console,
606
+ copy: bool,
607
+ accent: str,
608
+ ) -> None:
609
+ if out:
610
+ write_text_to_file(out, result)
611
+ console.print(f"[green]Saved to {out}[/green]")
612
+ else:
613
+ console.print(Text(result, style=accent))
614
+ if copy:
615
+ if copy_to_clipboard(result):
616
+ console.print("[green]Copied to clipboard[/green]")
617
+ else:
618
+ console.print("[yellow]Unable to copy to clipboard[/yellow]")
619
+
620
+
621
+ def interactive_mode(console: Console, accent: str) -> None:
622
+ console.print(f"[bold {accent}]Interactive mode[/bold {accent}]")
623
+
624
+ class BackAction(Exception):
625
+ pass
626
+
627
+ def maybe_exit(value: str) -> None:
628
+ if value.strip().lower() in {"bye", "exit", "q", "quit"}:
629
+ console.print(f"[bold {accent}]Goodbye[/bold {accent}]")
630
+ raise SystemExit(0)
631
+
632
+ def ask_text(label: str, default: Optional[str] = None) -> str:
633
+ value = Prompt.ask(label, default=default)
634
+ maybe_exit(value)
635
+ return value
636
+
637
+ def ask_int(label: str, default: int) -> int:
638
+ attempts = 0
639
+ while attempts < 3:
640
+ value = Prompt.ask(label, default=str(default))
641
+ maybe_exit(value)
642
+ try:
643
+ return int(value)
644
+ except ValueError:
645
+ attempts += 1
646
+ console.print("[red]Invalid number.[/red] Please try again.")
647
+ console.print("[red]Too many invalid attempts. Exiting.[/red]")
648
+ raise SystemExit(2)
649
+
650
+ def choose_option(label: str, options: list[str], default_index: int = 1) -> str:
651
+ attempts = 0
652
+ while attempts < 3:
653
+ console.print(f"\n{label}:")
654
+ def format_option(key: str, text: str) -> str:
655
+ spacer = " " if len(key) == 1 else " "
656
+ return f" [{accent}]{key}[/{accent}]){spacer}{text}"
657
+
658
+ console.print(format_option("0", "Back"))
659
+ key_map: dict[str, str] = {}
660
+ numeric_index = 1
661
+ for option in options:
662
+ if option == "identify":
663
+ key_map["i"] = option
664
+ console.print(format_option("i", option))
665
+ continue
666
+ key = str(numeric_index)
667
+ key_map[key] = option
668
+ console.print(format_option(key, option))
669
+ numeric_index += 1
670
+ console.print(format_option("q", "Quit"))
671
+ default_hint = f"[{accent}]\\[{default_index}][/{accent}]"
672
+ raw = console.input(f"Select option {default_hint}: ").strip().lower()
673
+ if raw == "":
674
+ raw = str(default_index)
675
+ if raw == "0":
676
+ raise BackAction()
677
+ if raw in key_map:
678
+ return key_map[raw]
679
+ maybe_exit(raw)
680
+ try:
681
+ choice = int(raw)
682
+ except ValueError:
683
+ attempts += 1
684
+ console.print("[red]Invalid selection.[/red] Please try again.")
685
+ continue
686
+ selected = key_map.get(str(choice))
687
+ if selected:
688
+ return selected
689
+ attempts += 1
690
+ console.print("[red]Invalid selection.[/red] Please try again.")
691
+ console.print("[red]Too many invalid attempts. Exiting.[/red]")
692
+ raise SystemExit(2)
693
+
694
+ def ask_yes_no(label: str, default: bool = False) -> bool:
695
+ attempts = 0
696
+ default_str = "y" if default else "n"
697
+ hint = "Y/n" if default else "y/N"
698
+ hint_markup = f"[{accent}]\\[{hint}][/{accent}]"
699
+ while attempts < 3:
700
+ value = console.input(f"{label} {hint_markup}: ")
701
+ if value.strip() == "":
702
+ value = default_str
703
+ maybe_exit(value)
704
+ normalized = value.strip().lower()
705
+ if normalized in {"y", "yes"}:
706
+ return True
707
+ if normalized in {"n", "no"}:
708
+ return False
709
+ attempts += 1
710
+ console.print("[red]Invalid input.[/red] Use yes/no.")
711
+ console.print("[red]Too many invalid attempts. Exiting.[/red]")
712
+ raise SystemExit(2)
713
+
714
+ # Helper: get input either text or file (reusable)
715
+ def _get_interactive_input(prompt_label: str) -> tuple[Optional[str], Optional[str]]:
716
+ while True:
717
+ choice = choose_option(prompt_label, ["enter custom text", "use file"], default_index=1)
718
+ if choice == "enter custom text":
719
+ txt = ask_text("Enter text", default="Hashsmith_Sample")
720
+ return txt, None
721
+ if choice == "use file":
722
+ fp = ask_text("File path")
723
+ try:
724
+ content = read_text_from_file(fp)
725
+ except ValueError as exc:
726
+ console.print(f"[bold red]Error:[/bold red] {exc}")
727
+ continue
728
+ return content, None
729
+
730
+ def _get_interactive_output() -> tuple[Optional[str], bool]:
731
+ copy_output = ask_yes_no("Copy output to clipboard?", default=True)
732
+ if ask_yes_no("Save output to file?", default=False):
733
+ out_choice = choose_option("Output path", ["use default output.txt", "enter custom path"], default_index=1)
734
+ out_path = "output.txt" if out_choice.startswith("use default") else ask_text("Output file path")
735
+ return out_path, copy_output
736
+ return None, copy_output
737
+
738
+ actions = ["encode", "decode", "hash", "crack", "set-theme", "identify"]
739
+ while True:
740
+ try:
741
+ action = choose_option("Choose action", actions, default_index=1)
742
+
743
+ if action == "set-theme":
744
+ theme_keys = list(THEMES.keys())
745
+ selected = choose_option("Select theme", theme_keys, default_index=1)
746
+ accent = THEMES.get(selected, "cyan")
747
+ render_banner(console, accent)
748
+ console.print(f"Theme set to [bold {accent}]{selected}[/bold {accent}]")
749
+ continue
750
+
751
+ if action in {"encode", "decode", "hash"}:
752
+ out_path, copy_output = _get_interactive_output()
753
+
754
+ if action == "encode":
755
+ enc_options = [
756
+ "base64", "base64url", "base32", "base85", "base58", "hex", "binary", "decimal", "octal",
757
+ "morse", "url", "caesar", "rot13", "vigenere", "xor", "atbash",
758
+ "baconian", "leet", "reverse", "brainf*ck", "railfence", "polybius", "unicode",
759
+ ]
760
+ enc_type = choose_option("Encoding type", enc_options, default_index=1)
761
+ shift = ask_int("Caesar shift", default=3) if enc_type == "caesar" else 3
762
+ text, file_path = _get_interactive_input("Input source")
763
+ key = None
764
+ rails = 2
765
+ if enc_type in {"vigenere", "xor"}:
766
+ key = ask_text("Key")
767
+ if enc_type == "railfence":
768
+ rails = ask_int("Rails", default=2)
769
+ args = argparse.Namespace(type=enc_type, text=text or None, file=file_path, shift=shift, key=key, rails=rails)
770
+ try:
771
+ result = handle_encode(args, console)
772
+ output_result(result, out_path, console, copy=copy_output)
773
+ return
774
+ except ValueError as exc:
775
+ console.print(f"[bold red]Error:[/bold red] {exc}")
776
+ continue
777
+
778
+ if action == "decode":
779
+ dec_options = [
780
+ "base64", "base64url", "base32", "base85", "base58", "hex", "binary", "decimal", "octal",
781
+ "morse", "url", "caesar", "rot13", "vigenere", "xor", "atbash",
782
+ "baconian", "leet", "reverse", "brainf*ck", "railfence", "polybius", "unicode",
783
+ ]
784
+ dec_type = choose_option("Decoding type", dec_options, default_index=1)
785
+ shift = ask_int("Caesar shift", default=3) if dec_type == "caesar" else 3
786
+ text, file_path = _get_interactive_input("Input source")
787
+ key = None
788
+ rails = 2
789
+ if dec_type in {"vigenere", "xor"}:
790
+ key = ask_text("Key")
791
+ if dec_type == "railfence":
792
+ rails = ask_int("Rails", default=2)
793
+ args = argparse.Namespace(type=dec_type, text=text or None, file=file_path, shift=shift, key=key, rails=rails)
794
+ try:
795
+ result = handle_decode(args, console)
796
+ output_result(result, out_path, console, copy=copy_output)
797
+ return
798
+ except ValueError as exc:
799
+ console.print(f"[bold red]Error:[/bold red] {exc}")
800
+ continue
801
+
802
+ hash_options = [
803
+ "md5", "md4", "sha1", "sha224", "sha256", "sha384", "sha512", "sha3_224", "sha3_256", "sha3_512",
804
+ "blake2b", "blake2s", "ntlm", "mysql323", "mysql41", "bcrypt",
805
+ "argon2", "scrypt", "mssql2000", "mssql2005", "mssql2012", "postgres",
806
+ ]
807
+ hash_type = choose_option("Hash type", hash_options, default_index=3)
808
+ text, file_path = _get_interactive_input("Input source")
809
+ salt = ""
810
+ if hash_type == "bcrypt":
811
+ salt = ask_text("Salt (or rounds)", default="12")
812
+ elif hash_type == "postgres":
813
+ salt = ask_text("Username (salt)")
814
+ elif ask_yes_no("Use salt?", default=False):
815
+ salt = ask_text("Salt value")
816
+ salt_mode = choose_option("Salt mode", ["prefix", "suffix"], default_index=1) if salt else "prefix"
817
+ out_encoding = choose_option("Output encoding", ["hex", "base58"], default_index=1)
818
+ args = argparse.Namespace(
819
+ type=hash_type,
820
+ text=text or None,
821
+ file=file_path,
822
+ salt=salt,
823
+ salt_mode=salt_mode,
824
+ out_encoding=out_encoding,
825
+ )
826
+ try:
827
+ result = handle_hash(args, console)
828
+ output_result(result, out_path, console, copy=copy_output)
829
+ return
830
+ except ValueError as exc:
831
+ console.print(f"[bold red]Error:[/bold red] {exc}")
832
+ continue
833
+
834
+ if action == "identify":
835
+ text = ask_text("Enter text")
836
+ args = argparse.Namespace(text=text, file=None)
837
+ try:
838
+ result = handle_identify(args, console)
839
+ console.print(Text(result, style=accent))
840
+ return
841
+ except ValueError as exc:
842
+ console.print(f"[bold red]Error:[/bold red] {exc}")
843
+ continue
844
+
845
+ crack_type = choose_option(
846
+ "Hash type",
847
+ [
848
+ "auto", "md5", "md4", "sha1", "sha224", "sha256", "sha384", "sha512", "sha3_224", "sha3_256", "sha3_512",
849
+ "blake2b", "blake2s", "ntlm", "mysql323", "mysql41", "bcrypt",
850
+ "argon2", "scrypt", "mssql2000", "mssql2005", "mssql2012", "postgres",
851
+ ],
852
+ default_index=1,
853
+ )
854
+ mode = choose_option("Mode", ["dict", "brute"], default_index=1)
855
+ while True:
856
+ target_hash = ask_text("Target hash")
857
+ if crack_type != "auto" and crack_type not in {"bcrypt", "argon2", "scrypt", "postgres"} and not is_hex_string(target_hash) and not target_hash.startswith("*") and not target_hash.lower().startswith("0x0100"):
858
+ console.print("[bold red]Error:[/bold red] Hash must be hexadecimal (0-9, a-f).")
859
+ continue
860
+ break
861
+ if crack_type == "auto":
862
+ candidates = detect_hash_types(target_hash)
863
+ if not candidates:
864
+ console.print("[bold red]Error:[/bold red] Unable to detect hash type.")
865
+ continue
866
+ if len(candidates) == 1:
867
+ crack_type = candidates[0]
868
+ else:
869
+ crack_type = choose_option("Detected types", candidates, default_index=1)
870
+ salt = ""
871
+ if ask_yes_no("Use salt?", default=False):
872
+ salt = ask_text("Salt value")
873
+ salt_mode = choose_option("Salt mode", ["prefix", "suffix"], default_index=1) if salt else "prefix"
874
+
875
+ if mode == "dict":
876
+ wordlist_choice = choose_option("Wordlist", ["use default wordlists/common.txt", "enter custom path"], default_index=1)
877
+ wordlist = "wordlists/common.txt" if wordlist_choice.startswith("use default") else ask_text("Wordlist path")
878
+ workers = ask_int("Workers", default=os.cpu_count() or 1)
879
+ copy_output = ask_yes_no("Copy cracked password to clipboard?", default=True)
880
+ args = argparse.Namespace(
881
+ type=crack_type,
882
+ target_hash=target_hash,
883
+ mode=mode,
884
+ wordlist=wordlist,
885
+ charset="",
886
+ min_len=1,
887
+ max_len=4,
888
+ salt=salt,
889
+ salt_mode=salt_mode,
890
+ workers=workers,
891
+ copy=copy_output,
892
+ )
893
+ raise SystemExit(handle_crack(args, console, accent))
894
+
895
+ charset_choice = choose_option("Charset", ["use default [a-z0-9]", "enter custom"], default_index=1)
896
+ charset = "abcdefghijklmnopqrstuvwxyz0123456789" if charset_choice.startswith("use default") else ask_text("Charset")
897
+ min_len = ask_int("Min length", default=1)
898
+ max_len = ask_int("Max length", default=4)
899
+ copy_output = ask_yes_no("Copy cracked password to clipboard?", default=True)
900
+ args = argparse.Namespace(
901
+ type=crack_type,
902
+ target_hash=target_hash,
903
+ mode=mode,
904
+ wordlist=None,
905
+ charset=charset,
906
+ min_len=min_len,
907
+ max_len=max_len,
908
+ salt=salt,
909
+ salt_mode=salt_mode,
910
+ workers=1,
911
+ copy=copy_output,
912
+ )
913
+ raise SystemExit(handle_crack(args, console, accent))
914
+ except BackAction:
915
+ continue
916
+
917
+
918
+ def main() -> None:
919
+ parser = build_parser()
920
+ args = parser.parse_args()
921
+ console = Console()
922
+
923
+ accent = THEMES.get(args.theme, "cyan")
924
+
925
+ if getattr(args, "help_all", False):
926
+ parser.print_help()
927
+ subparser_map = getattr(args, "_subparser_map", {})
928
+ for name, subparser in subparser_map.items():
929
+ print(f"\n{name} command help:\n")
930
+ subparser.print_help()
931
+ raise SystemExit(0)
932
+
933
+ try:
934
+ if args.identify:
935
+ if args.command and args.command != "identify":
936
+ console.print("[bold red]Error:[/bold red] -id cannot be combined with another command.")
937
+ raise SystemExit(2)
938
+ if not args.no_banner:
939
+ render_banner(console, accent)
940
+ try:
941
+ result = handle_identify(args, console)
942
+ output_identify_result(result, args.out, console, copy=args.copy, accent=accent)
943
+ except ValueError as exc:
944
+ console.print(f"[bold red]Error:[/bold red] {exc}")
945
+ raise SystemExit(2)
946
+ elif args.command is None:
947
+ if not args.no_banner:
948
+ render_banner(console, accent)
949
+ interactive_mode(console, accent)
950
+ elif args.command == "interactive":
951
+ if not args.no_banner:
952
+ render_banner(console, accent)
953
+ interactive_mode(console, accent)
954
+ elif args.command == "encode":
955
+ if not args.no_banner:
956
+ render_banner(console, accent)
957
+ try:
958
+ result = handle_encode(args, console)
959
+ output_result(result, args.out, console, copy=args.copy)
960
+ except ValueError as exc:
961
+ console.print(f"[bold red]Error:[/bold red] {exc}")
962
+ raise SystemExit(2)
963
+ elif args.command == "decode":
964
+ if not args.no_banner:
965
+ render_banner(console, accent)
966
+ try:
967
+ result = handle_decode(args, console)
968
+ output_result(result, args.out, console, copy=args.copy)
969
+ except ValueError as exc:
970
+ console.print(f"[bold red]Error:[/bold red] {exc}")
971
+ raise SystemExit(2)
972
+ elif args.command == "hash":
973
+ if not args.no_banner:
974
+ render_banner(console, accent)
975
+ try:
976
+ result = handle_hash(args, console)
977
+ output_result(result, args.out, console, copy=args.copy)
978
+ except ValueError as exc:
979
+ console.print(f"[bold red]Error:[/bold red] {exc}")
980
+ raise SystemExit(2)
981
+ elif args.command == "identify":
982
+ if not args.no_banner:
983
+ render_banner(console, accent)
984
+ try:
985
+ result = handle_identify(args, console)
986
+ output_identify_result(result, args.out, console, copy=args.copy, accent=accent)
987
+ except ValueError as exc:
988
+ console.print(f"[bold red]Error:[/bold red] {exc}")
989
+ raise SystemExit(2)
990
+ elif args.command == "crack":
991
+ if not args.no_banner:
992
+ render_banner(console, accent)
993
+ if args.type != "auto" and args.type not in {"bcrypt", "argon2", "scrypt", "postgres"} and not is_hex_string(args.target_hash) and not args.target_hash.startswith("*") and not args.target_hash.lower().startswith("0x0100"):
994
+ console.print("[bold red]Error:[/bold red] Hash must be hexadecimal (0-9, a-f).")
995
+ raise SystemExit(2)
996
+ if args.type == "auto":
997
+ candidates = detect_hash_types(args.target_hash)
998
+ if not candidates:
999
+ console.print("[bold red]Error:[/bold red] Unable to detect hash type.")
1000
+ raise SystemExit(2)
1001
+ if len(candidates) > 1:
1002
+ console.print(f"[bold yellow]Multiple candidates:[/bold yellow] {', '.join(candidates)}")
1003
+ console.print("Use --type to select one.")
1004
+ raise SystemExit(2)
1005
+ args.type = candidates[0]
1006
+ raise SystemExit(handle_crack(args, console, accent))
1007
+ else:
1008
+ parser.print_help()
1009
+ except KeyboardInterrupt:
1010
+ console.print(f"\n[bold {accent}]Goodbye[/bold {accent}]")
1011
+ raise SystemExit(0)
1012
+ except Exception:
1013
+ console.print("[bold red]Error:[/bold red] An unexpected error occurred. Please report this issue.")
1014
+ raise SystemExit(1)