from __future__ import annotations import hashlib import json import tempfile import zipfile from collections import defaultdict from pathlib import Path from typing import Any from cracklab.server.repositories.attempts import AttemptsRepository from cracklab.server.repositories.audit import AuditRepository from cracklab.server.repositories.credentials import CredentialsRepository from cracklab.server.repositories.jobs import JobsRepository from cracklab.server.repositories.reservations import ReservationsRepository from cracklab.server.repositories.results import ResultsRepository class JobResultError(ValueError): pass class JobResultService: def __init__(self, conn) -> None: self._conn = conn self._attempts = AttemptsRepository(conn) self._audit = AuditRepository(conn) self._credentials = CredentialsRepository(conn) self._jobs = JobsRepository(conn) self._reservations = ReservationsRepository(conn) self._results = ResultsRepository(conn) def import_result(self, package_path: Path | str) -> dict[str, Any]: package = Path(package_path) if not package.is_file(): raise JobResultError(f"Result package not found: {package}") with tempfile.TemporaryDirectory(prefix="cracklab-result-") as tmp: root = Path(tmp) try: with zipfile.ZipFile(package, "r") as zf: self._safe_extract(zf, root) except zipfile.BadZipFile as exc: raise JobResultError( f"Invalid result package: {package}" ) from exc result_path, show_path = self._find_result_files(root) result = self._read_json(result_path) self._validate_result_sha256(result) job_id = result.get("job_id") if not isinstance(job_id, str) or not job_id: raise JobResultError("Result is missing job_id") self._conn.execute("BEGIN IMMEDIATE") try: job = self._jobs.get_job(job_id) if job is None: raise JobResultError(f"Unknown Job: {job_id}") idempotent = self._check_idempotency(job, result) if idempotent: self._conn.rollback() return { "job_id": job_id, "status": "ALREADY_IMPORTED", "result_sha256": result["result_sha256"], } self._validate_job_contract(job, result) job_handshakes = self._load_job_handshakes(job_id) unique_hashes = { row["hash22000"] for row in job_handshakes } if len(unique_hashes) != job["hash_count"]: raise JobResultError( "Job hash_count does not match unique " "crack_job_handshakes hash22000 values" ) found = self._parse_show_file( show_path, job_handshakes, ) credential_stats = self._import_credentials( found, job_handshakes, ) attempt_count = self._register_attempts( job, result, job_handshakes, ) self._update_steps(job_id, result["steps"]) released = self._release_reservations(job_id) imported_at = self._now() self._jobs.update_job_status( job_id, "COMPLETED", ) self._jobs.update_job_result( job_id, result_sha256=result["result_sha256"], imported_at=imported_at, ) self._results.create_result( job_id, "COMPLETED", result, imported_at=imported_at, ) self._audit.create_event( event_type="JOB_RESULT_IMPORTED", job_id=job_id, client_id=job["client_id"], method_id=job["method_id"], method_version=job["method_version"], details={ "result_sha256": result["result_sha256"], "credentials_imported": credential_stats["imported"], "credentials_already_present": ( credential_stats["already_present"] ), "attempts_completed": attempt_count, "reservations_released": released, }, ) self._conn.commit() return { "job_id": job_id, "status": "COMPLETED", "result_sha256": result["result_sha256"], "credentials_imported": credential_stats["imported"], "credentials_already_present": ( credential_stats["already_present"] ), "attempts_completed": attempt_count, "reservations_released": released, } except Exception: self._conn.rollback() raise @staticmethod def _now() -> str: from datetime import datetime, timezone return datetime.now(timezone.utc).isoformat( timespec="milliseconds" ).replace("+00:00", "Z") @staticmethod def _read_json(path: Path) -> dict[str, Any]: try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: raise JobResultError( f"Invalid result.json: {path}" ) from exc if not isinstance(value, dict): raise JobResultError("result.json must contain a JSON object") return value @staticmethod def _safe_extract( zf: zipfile.ZipFile, destination: Path, ) -> None: destination = destination.resolve() for member in zf.infolist(): target = (destination / member.filename).resolve() try: target.relative_to(destination) except ValueError as exc: raise JobResultError( f"Unsafe path in result package: {member.filename}" ) from exc if member.is_dir(): target.mkdir(parents=True, exist_ok=True) continue target.parent.mkdir(parents=True, exist_ok=True) with zf.open(member) as src, target.open("wb") as dst: while True: chunk = src.read(1024 * 1024) if not chunk: break dst.write(chunk) @staticmethod def _find_result_files( root: Path, ) -> tuple[Path, Path]: manifests = list(root.rglob("result.json")) if len(manifests) != 1: raise JobResultError( "Result package must contain exactly one result.json" ) show_files = list(root.rglob("hashcat-show.txt")) if len(show_files) != 1: raise JobResultError( "Result package must contain exactly one hashcat-show.txt" ) return manifests[0], show_files[0] @staticmethod def _validate_result_sha256(result: dict[str, Any]) -> None: expected = result.get("result_sha256") if not isinstance(expected, str) or not expected: raise JobResultError( "Result is missing result_sha256" ) payload = dict(result) payload.pop("result_sha256", None) payload_bytes = json.dumps( payload, ensure_ascii=False, indent=2, ).encode("utf-8") actual = hashlib.sha256(payload_bytes).hexdigest() if actual != expected: raise JobResultError( "Invalid result_sha256: " f"expected {expected}, calculated {actual}" ) def _check_idempotency( self, job, result: dict[str, Any], ) -> bool: result_sha256 = result["result_sha256"] stored_job_sha = job["result_sha256"] stored_result = self._results.get_result(job["id"]) if stored_job_sha is not None: if stored_job_sha != result_sha256: raise JobResultError( f"Job {job['id']} already has a different result_sha256" ) return True if stored_result is not None: stored_payload = self._results.decode_result(stored_result) stored_sha = stored_payload.get("result_sha256") if stored_sha != result_sha256: raise JobResultError( f"Job {job['id']} already has a different imported result" ) return True if job["imported_at"] is not None: raise JobResultError( f"Job {job['id']} is marked imported without a stored result" ) if job["status"] == "COMPLETED": raise JobResultError( f"Job {job['id']} is already COMPLETED without an imported result" ) return False def _validate_job_contract( self, job, result: dict[str, Any], ) -> None: if result.get("schema_version") != 1: raise JobResultError( "Unsupported result schema_version: " f"{result.get('schema_version')!r}" ) if result.get("job_id") != job["id"]: raise JobResultError( "Result Job ID does not match the database Job" ) if result.get("status") != "COMPLETED": raise JobResultError( "Only COMPLETED results can be imported" ) if result.get("hash_file_sha256") != job["hash_file_sha256"]: raise JobResultError( "Result hash_file_sha256 does not match the Job" ) if result.get("hash_count") != job["hash_count"]: raise JobResultError( "Result hash_count does not match the Job" ) method = result.get("method") if not isinstance(method, dict): raise JobResultError( "Result is missing method object" ) if method.get("id") != job["method_id"]: raise JobResultError( "Result method id does not match the Job" ) if method.get("version") != job["method_version"]: raise JobResultError( "Result method version does not match the Job" ) completed_at = result.get("completed_at") if not isinstance(completed_at, str) or not completed_at: raise JobResultError( "Result is missing completed_at" ) result_steps = result.get("steps") if not isinstance(result_steps, list): raise JobResultError( "Result is missing steps list" ) if len(result_steps) != job["step_count"]: raise JobResultError( "Result step count does not match the Job" ) db_steps = self._jobs.list_job_steps(job["id"]) if len(db_steps) != job["step_count"]: raise JobResultError( "Database Job step count does not match crack_job_steps" ) db_by_no = { row["step_no"]: row for row in db_steps } seen_step_numbers: set[int] = set() for step in result_steps: if not isinstance(step, dict): raise JobResultError( "Each result step must be an object" ) step_no = step.get("step_no") if not isinstance(step_no, int): raise JobResultError( f"Invalid result step_no: {step_no!r}" ) if step_no in seen_step_numbers: raise JobResultError( f"Duplicate result step_no: {step_no}" ) seen_step_numbers.add(step_no) db_step = db_by_no.get(step_no) if db_step is None: raise JobResultError( f"Result contains unknown step_no: {step_no}" ) if step.get("step_id") != db_step["step_id"]: raise JobResultError( f"Step {step_no}: step_id does not match the Job" ) if step.get("session") != db_step["session_name"]: raise JobResultError( f"Step {step_no}: session does not match the Job" ) if step.get("status") != "COMPLETED": raise JobResultError( f"Step {step_no}: result status must be COMPLETED" ) def _load_job_handshakes( self, job_id: str, ) -> list[dict[str, Any]]: rows = self._jobs.list_job_handshakes(job_id) if not rows: raise JobResultError( f"Job {job_id} contains no handshakes" ) result = [] for row in rows: result.append( { "handshake_id": row["handshake_id"], "hash22000": row["hash22000"], "access_point_id": row["access_point_id"], } ) return result @staticmethod def _normalize_mac(value: str) -> str: normalized = value.strip().lower().replace(":", "").replace("-", "") if len(normalized) != 12: raise JobResultError( f"Invalid MAC address: {value!r}" ) try: int(normalized, 16) except ValueError as exc: raise JobResultError( f"Invalid MAC address: {value!r}" ) from exc return normalized @classmethod def _hash_identity( cls, hash22000: str, ) -> tuple[str, str, str]: parts = hash22000.split("*") if len(parts) < 6 or parts[0] != "WPA": raise JobResultError( f"Unsupported hash22000 format: {hash22000}" ) if parts[1] not in {"01", "02"}: raise JobResultError( f"Unsupported WPA hash type: {parts[1]}" ) hash_value = parts[2].strip().lower() ap_mac = cls._normalize_mac(parts[3]) client_mac = cls._normalize_mac(parts[4]) if not hash_value: raise JobResultError( f"Invalid hash22000 hash value: {hash22000}" ) return ( hash_value, ap_mac, client_mac, ) @classmethod def _parse_show_identity( cls, line: str, ) -> tuple[str, str, str, str]: fields = line.split(":", 4) if len(fields) != 5: raise JobResultError( f"Invalid Hashcat --show line: {line}" ) hash_value = fields[0].strip().lower() ap_mac = cls._normalize_mac(fields[1]) client_mac = cls._normalize_mac(fields[2]) password = fields[4] if not hash_value: raise JobResultError( f"Invalid Hashcat --show hash: {line}" ) return ( hash_value, ap_mac, client_mac, password, ) def _parse_show_file( self, path: Path, job_handshakes: list[dict[str, Any]], ) -> list[tuple[str, str]]: identity_to_hashes: dict[ tuple[str, str, str], list[str], ] = defaultdict(list) for row in job_handshakes: identity = self._hash_identity(row["hash22000"]) hashes = identity_to_hashes[identity] if row["hash22000"] not in hashes: hashes.append(row["hash22000"]) found: list[tuple[str, str]] = [] seen: set[tuple[str, str]] = set() for line_no, raw_line in enumerate( path.read_text(encoding="utf-8").splitlines(), start=1, ): line = raw_line.strip() if not line: continue hash_value, ap_mac, client_mac, password = ( self._parse_show_identity(line) ) identity = ( hash_value, ap_mac, client_mac, ) hashes = identity_to_hashes.get(identity) if not hashes: raise JobResultError( "Result contains an unknown Hashcat --show line " f"{line_no}: {line}" ) for hash22000 in hashes: item = (hash22000, password) if item not in seen: seen.add(item) found.append(item) return found def _import_credentials( self, found: list[tuple[str, str]], job_handshakes: list[dict[str, Any]], ) -> dict[str, int]: by_hash: dict[str, list[dict[str, Any]]] = defaultdict(list) for row in job_handshakes: by_hash[row["hash22000"]].append(row) imported = 0 already_present = 0 for hash22000, password in found: mappings = by_hash.get(hash22000) if not mappings: raise JobResultError( "Hashcat --show contains a hash that is not part " f"of the Job: {hash22000}" ) for mapping in mappings: handshake_id = mapping["handshake_id"] access_point_id = mapping["access_point_id"] if access_point_id is None: raise JobResultError( "Cannot import credential for handshake " f"{handshake_id}: access_point_id is NULL" ) existing = self._credentials.get_credential( access_point_id, handshake_id, password, ) if existing is not None: already_present += 1 self._credentials.mark_access_point_cracked( access_point_id, ) continue self._credentials.create_hashcat_credential( access_point_id, handshake_id, password, ) imported += 1 return { "imported": imported, "already_present": already_present, } def _register_attempts( self, job, result: dict[str, Any], job_handshakes: list[dict[str, Any]], ) -> int: if result["status"] != "COMPLETED": return 0 completed_at = result["completed_at"] count = 0 for row in job_handshakes: existing = self._attempts.get_attempt( row["handshake_id"], job["method_id"], job["method_version"], ) if existing is not None: if ( existing["job_id"] != job["id"] or existing["status"] != "COMPLETED" ): raise JobResultError( "Handshake " f"{row['handshake_id']} already has a " "conflicting attempt for this method/version" ) continue self._attempts.create_attempt( handshake_id=row["handshake_id"], method_id=job["method_id"], method_version=job["method_version"], job_id=job["id"], completed_at=completed_at, ) count += 1 return count def _update_steps( self, job_id: str, result_steps: list[dict[str, Any]], ) -> None: for step in result_steps: step_no = step["step_no"] self._jobs.update_job_step( job_id=job_id, step_no=step_no, status="COMPLETED", started_at=step.get("started_at"), completed_at=step.get("completed_at"), exit_code=step.get("exit_code"), restore_seen=( 1 if step.get("restore_seen") else 0 ), error=step.get("error"), ) updated = self._jobs.get_job_step( job_id, step_no, ) if updated is None: raise JobResultError( f"Job step disappeared during result import: " f"{step_no}" ) def _release_reservations( self, job_id: str, ) -> int: released = 0 for reservation in self._reservations.list_job_reservations( job_id ): if reservation["status"] != "ACTIVE": continue self._reservations.update_status( reservation["id"], "RELEASED", ) released += 1 return released