Update public source
This commit is contained in:
@@ -0,0 +1,740 @@
|
||||
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
|
||||
Reference in New Issue
Block a user