741 lines
22 KiB
Python
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
|