Files
wifi-gps-mapper-public/cracklab/server/services/job_result.py
T
2026-10-06 21:08:12 +03:00

741 lines
22 KiB
Python

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