"""Run bounded offline hashcat attacks against supported capture artifacts.""" from __future__ import annotations import os from pathlib import Path import shutil import subprocess import tempfile import time from dataclasses import dataclass, field from typing import Callable, Optional, Sequence, Union from ..artifacts import HashArtifact, identify_hash_mode @dataclass class HashCrack: value: str hashcat_mode: Optional[int] = None hash_format: Optional[str] = None cracked: bool = False plaintext: Optional[str] = None duration_seconds: Optional[float] = None # pending | cracked | not_cracked | timeout | error | unsupported state: str = "pending" error: Optional[str] = None def to_dict(self) -> dict: return { "value": self.value, "hashcat_mode": self.hashcat_mode, "hash_format": self.hash_format, "cracked": self.cracked, "plaintext": self.plaintext, "duration_seconds": self.duration_seconds, "state": self.state, "error": self.error, } # Keep the bundled demo list first; authorized deployments may add a system list. DEFAULT_WORDLISTS = ( str(Path(__file__).resolve().parents[3] / "demo_files" / "demo_wordlist.txt"), "/usr/share/wordlists/rockyou.txt", ) def build_crack_command(hashcat_path: str, mode: int, hash_file: str, wordlists: Sequence[str], potfile: str) -> list[str]: """Build the hashcat dictionary-attack command.""" cmd = [hashcat_path, "-m", str(mode), "-a", "0", "-D", "1,2", hash_file, *wordlists, "--potfile-path", potfile, "--force"] return cmd def build_show_command(hashcat_path: str, mode: int, hash_file: str, potfile: str) -> list[str]: """Build the hashcat command used to read cracked results.""" return [hashcat_path, "-m", str(mode), "--show", "--potfile-path", potfile, hash_file] def parse_show_output(stdout: str) -> Optional[str]: """Extract plaintext from ``hashcat --show`` output.""" if not stdout: return None for line in stdout.strip().splitlines(): if ":" in line: return line.rsplit(":", 1)[1] return None def crack_hash( artifact: Union[HashArtifact, str], *, wordlists: Sequence[str] = DEFAULT_WORDLISTS, hashcat_path: str = "hashcat", timeout: int = 300, potfile: Optional[str] = None, runner: Callable[..., subprocess.CompletedProcess] = subprocess.run, which: Callable[[str], Optional[str]] = shutil.which, ) -> HashCrack: """Crack one artifact and convert runtime failures into a result state.""" if isinstance(artifact, HashArtifact): value = artifact.value mode = artifact.hashcat_mode fmt = artifact.hash_format else: value = str(artifact) mode = None fmt = None if mode is None: mode, fmt = identify_hash_mode(value, hint_format=fmt) result = HashCrack(value=value, hashcat_mode=mode, hash_format=fmt) if mode is None: result.state = "unsupported" result.error = "No supported hashcat mode was detected" return result if which(hashcat_path) is None: result.state = "error" result.error = "hashcat is not installed or not available on PATH" return result available = [w for w in wordlists if os.path.exists(w)] if not available: result.state = "error" result.error = "No wordlist is available" return result tmpdir = tempfile.mkdtemp(prefix="hashcrack_") hash_file = os.path.join(tmpdir, "hash.txt") pot = potfile or os.path.join(tmpdir, "hashcat.potfile") try: with open(hash_file, "w", encoding="utf-8") as fh: fh.write(value.strip() + "\n") start = time.time() try: runner(build_crack_command(hashcat_path, mode, hash_file, available, pot), capture_output=True, text=True, timeout=timeout) except subprocess.TimeoutExpired: result.state = "timeout" result.duration_seconds = round(time.time() - start, 2) result.error = f"hashcat timed out after {timeout}s" return result show = runner(build_show_command(hashcat_path, mode, hash_file, pot), capture_output=True, text=True, timeout=timeout) result.duration_seconds = round(time.time() - start, 2) plaintext = parse_show_output(getattr(show, "stdout", "") or "") if plaintext is not None: result.cracked = True result.plaintext = plaintext result.state = "cracked" else: result.state = "not_cracked" return result except Exception as exc: # Enrichment failures must not stop capture analysis. result.state = "error" result.error = str(exc)[:300] return result finally: shutil.rmtree(tmpdir, ignore_errors=True)