#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Общий сервис импорта и экспорта capture sessions в формате .wifitrip. Модуль не зависит от Flask и scanner. CLI и Web UI используют один и тот же сервисный код. """ import hashlib import json import logging import os import re import tempfile import time import zipfile from datetime import datetime, timezone from pathlib import Path logger = logging.getLogger(__name__) WIFITRIP_FORMAT = "wifitrip" WIFITRIP_VERSION = 1 SESSION_HASH_VERSION = 1 REQUIRED_FILES = ( "manifest.json", "sessions.json", "gps_tracks.json", "access_points.json", "observations.json", "handshakes.json", "credentials.json", ) SHA256_RE = re.compile(r"^[0-9a-f]{64}$") class WifitripError(ValueError): """Ошибка формата или обработки .wifitrip.""" def _json_bytes(value): return json.dumps( value, ensure_ascii=False, separators=(",", ":"), allow_nan=False, ).encode("utf-8") def _json_loads(data, filename): try: return json.loads( data.decode("utf-8"), parse_constant=_reject_non_finite, ) except UnicodeDecodeError as exc: raise WifitripError( f"{filename} must be valid UTF-8." ) from exc except json.JSONDecodeError as exc: raise WifitripError( f"{filename} contains invalid JSON: {exc}" ) from exc def _reject_non_finite(value): raise ValueError( f"non-finite JSON number is not allowed: {value}" ) def _require_dict(value, name): if not isinstance(value, dict): raise WifitripError( f"{name} must contain a JSON object." ) def _require_list(value, name): if not isinstance(value, list): raise WifitripError( f"{name} must contain a JSON array." ) def _require_string(value, field, allow_none=False): if value is None and allow_none: return if not isinstance(value, str): raise WifitripError( f"{field} must be a string." ) def _require_number(value, field, allow_none=True): if value is None and allow_none: return if not isinstance(value, (int, float)) or isinstance(value, bool): raise WifitripError( f"{field} must be a number or null." ) def _require_integer(value, field, allow_none=False): if value is None and allow_none: return if not isinstance(value, int) or isinstance(value, bool): raise WifitripError( f"{field} must be an integer." ) def _require_bssid(value, field): _require_string(value, field) if not value.strip(): raise WifitripError( f"{field} must not be empty." ) def _validate_hash(value, field): _require_string(value, field) if not SHA256_RE.fullmatch(value): raise WifitripError( f"{field} must be a 64-character lowercase SHA-256 hash." ) def _read_package(path): path = Path(path) if not path.exists(): raise WifitripError( f"File does not exist: {path}" ) if not path.is_file(): raise WifitripError( f"Not a regular file: {path}" ) try: archive = zipfile.ZipFile(path, "r") except (OSError, zipfile.BadZipFile) as exc: raise WifitripError( f"Invalid .wifitrip ZIP archive: {path}" ) from exc with archive: infos = archive.infolist() names = [info.filename for info in infos] if len(names) != len(set(names)): raise WifitripError( "Archive contains duplicate file names." ) missing = [ name for name in REQUIRED_FILES if name not in names ] if missing: raise WifitripError( "Archive is missing required files: " + ", ".join(missing) ) data = {} for filename in REQUIRED_FILES: try: raw = archive.read(filename) except KeyError as exc: raise WifitripError( f"Unable to read {filename}." ) from exc data[filename] = _json_loads( raw, filename, ) _validate_package(data) return data def _validate_package(data): for filename in REQUIRED_FILES: if filename not in data: raise WifitripError( f"Missing package member: {filename}" ) manifest = data["manifest.json"] sessions = data["sessions.json"] gps_tracks = data["gps_tracks.json"] access_points = data["access_points.json"] observations = data["observations.json"] handshakes = data["handshakes.json"] credentials = data["credentials.json"] _require_dict( manifest, "manifest.json", ) _require_list( sessions, "sessions.json", ) _require_list( gps_tracks, "gps_tracks.json", ) _require_list( access_points, "access_points.json", ) _require_list( observations, "observations.json", ) _require_list( handshakes, "handshakes.json", ) _require_list( credentials, "credentials.json", ) if manifest.get("format") != WIFITRIP_FORMAT: raise WifitripError( "Unsupported package format." ) if manifest.get("format_version") != WIFITRIP_VERSION: raise WifitripError( "Unsupported .wifitrip format version: " f"{manifest.get('format_version')}" ) _require_string( manifest.get("created_at"), "manifest.created_at", ) if manifest.get("sessions") != len(sessions): raise WifitripError( "manifest.sessions does not match sessions.json." ) session_hash_versions = manifest.get( "session_hash_versions" ) if not isinstance( session_hash_versions, list, ): raise WifitripError( "manifest.session_hash_versions must be a list." ) if not session_hash_versions: raise WifitripError( "manifest.session_hash_versions must not be empty." ) if any( version != SESSION_HASH_VERSION for version in session_hash_versions ): raise WifitripError( "Package contains an unsupported session hash version." ) session_map = {} session_versions = {} for index, session in enumerate(sessions): _require_dict( session, f"sessions.json[{index}]", ) session_hash = session.get( "session_hash" ) session_hash_version = session.get( "session_hash_version" ) _validate_hash( session_hash, f"sessions.json[{index}].session_hash", ) _require_integer( session_hash_version, f"sessions.json[{index}].session_hash_version", ) if session_hash_version != SESSION_HASH_VERSION: raise WifitripError( "Unsupported session hash version: " f"{session_hash_version}" ) key = ( session_hash, session_hash_version, ) if key in session_map: raise WifitripError( "Duplicate session hash in package: " f"{session_hash}" ) _require_string( session.get("start_time"), f"sessions.json[{index}].start_time", ) _require_string( session.get("end_time"), f"sessions.json[{index}].end_time", allow_none=True, ) session_type = session.get( "session_type" ) if session_type is not None: if session_type not in ( "gps", "non_gps", ): raise WifitripError( "Invalid session_type in " f"sessions.json[{index}]." ) session_map[key] = session session_versions[session_hash] = ( session_hash_version ) ap_map = {} for index, access_point in enumerate( access_points ): _require_dict( access_point, f"access_points.json[{index}]", ) bssid = access_point.get( "bssid" ) _require_bssid( bssid, f"access_points.json[{index}].bssid", ) if bssid in ap_map: raise WifitripError( "Duplicate BSSID in package: " f"{bssid}" ) ap_map[bssid] = True gps_sequences = {} for index, point in enumerate( gps_tracks ): _require_dict( point, f"gps_tracks.json[{index}]", ) session_hash = point.get( "session_hash" ) _validate_hash( session_hash, f"gps_tracks.json[{index}].session_hash", ) if session_hash not in session_versions: raise WifitripError( "GPS track references unknown session: " f"{session_hash}" ) sequence_no = point.get( "sequence_no" ) _require_integer( sequence_no, f"gps_tracks.json[{index}].sequence_no", ) if sequence_no < 0: raise WifitripError( "GPS sequence_no must not be negative." ) _require_string( point.get("timestamp"), f"gps_tracks.json[{index}].timestamp", allow_none=True, ) _require_number( point.get("latitude"), f"gps_tracks.json[{index}].latitude", ) _require_number( point.get("longitude"), f"gps_tracks.json[{index}].longitude", ) _require_number( point.get("speed"), f"gps_tracks.json[{index}].speed", ) sequences = gps_sequences.setdefault( session_hash, set(), ) if sequence_no in sequences: raise WifitripError( "Duplicate GPS sequence_no for session: " f"{session_hash}" ) sequences.add(sequence_no) for session_hash, sequences in gps_sequences.items(): expected = set( range(len(sequences)) ) if sequences != expected: raise WifitripError( "GPS sequence numbers must be contiguous " f"starting at zero for session {session_hash}." ) for index, observation in enumerate( observations ): _require_dict( observation, f"observations.json[{index}]", ) session_hash = observation.get( "session_hash" ) bssid = observation.get( "bssid" ) _validate_hash( session_hash, f"observations.json[{index}].session_hash", ) _require_bssid( bssid, f"observations.json[{index}].bssid", ) if session_hash not in session_versions: raise WifitripError( "Observation references unknown session: " f"{session_hash}" ) if bssid not in ap_map: raise WifitripError( "Observation references unknown BSSID: " f"{bssid}" ) _require_string( observation.get("observed_at"), f"observations.json[{index}].observed_at", ) _require_number( observation.get("latitude"), f"observations.json[{index}].latitude", ) _require_number( observation.get("longitude"), f"observations.json[{index}].longitude", ) _require_number( observation.get("speed"), f"observations.json[{index}].speed", ) _require_integer( observation.get("rssi"), f"observations.json[{index}].rssi", allow_none=True, ) _require_integer( observation.get("channel"), f"observations.json[{index}].channel", allow_none=True, ) _require_integer( observation.get("frequency"), f"observations.json[{index}].frequency", allow_none=True, ) for field in ( "essid", "encryption", "cipher", "akm", "country", ): _require_string( observation.get(field), f"observations.json[{index}].{field}", allow_none=True, ) handshake_keys = {} for index, handshake in enumerate( handshakes ): _require_dict( handshake, f"handshakes.json[{index}]", ) session_hash = handshake.get( "session_hash" ) bssid = handshake.get( "bssid" ) handshake_key = handshake.get( "handshake_key" ) _validate_hash( session_hash, f"handshakes.json[{index}].session_hash", ) _require_bssid( bssid, f"handshakes.json[{index}].bssid", ) _require_string( handshake_key, f"handshakes.json[{index}].handshake_key", ) if session_hash not in session_versions: raise WifitripError( "Handshake references unknown session: " f"{session_hash}" ) if bssid not in ap_map: raise WifitripError( "Handshake references unknown BSSID: " f"{bssid}" ) session_keys = handshake_keys.setdefault( session_hash, set(), ) if handshake_key in session_keys: raise WifitripError( "Duplicate handshake_key for session: " f"{session_hash}: {handshake_key}" ) session_keys.add(handshake_key) _require_string( handshake.get("type"), f"handshakes.json[{index}].type", ) _require_string( handshake.get("hash22000"), f"handshakes.json[{index}].hash22000", allow_none=True, ) _require_string( handshake.get("message_pair"), f"handshakes.json[{index}].message_pair", allow_none=True, ) _require_string( handshake.get("captured_at"), f"handshakes.json[{index}].captured_at", allow_none=True, ) _require_number( handshake.get("latitude"), f"handshakes.json[{index}].latitude", ) _require_number( handshake.get("longitude"), f"handshakes.json[{index}].longitude", ) _require_number( handshake.get("speed"), f"handshakes.json[{index}].speed", ) for index, credential in enumerate( credentials ): _require_dict( credential, f"credentials.json[{index}]", ) session_hash = credential.get( "session_hash" ) bssid = credential.get( "bssid" ) handshake_key = credential.get( "handshake_key" ) _validate_hash( session_hash, f"credentials.json[{index}].session_hash", ) _require_bssid( bssid, f"credentials.json[{index}].bssid", ) if session_hash not in session_versions: raise WifitripError( "Credential references unknown session: " f"{session_hash}" ) if bssid not in ap_map: raise WifitripError( "Credential references unknown BSSID: " f"{bssid}" ) if handshake_key is not None: _require_string( handshake_key, f"credentials.json[{index}].handshake_key", ) if handshake_key not in handshake_keys.get( session_hash, set(), ): raise WifitripError( "Credential references unknown handshake_key: " f"{session_hash}: {handshake_key}" ) _require_string( credential.get("password"), f"credentials.json[{index}].password", ) if not credential.get("password"): raise WifitripError( "Credential password must not be empty." ) _require_string( credential.get("source"), f"credentials.json[{index}].source", allow_none=True, ) _require_string( credential.get("created_at"), f"credentials.json[{index}].created_at", allow_none=True, ) verified = credential.get( "verified" ) _require_integer( verified, f"credentials.json[{index}].verified", allow_none=False, ) if verified not in (0, 1): raise WifitripError( "Credential verified must be 0 or 1." ) def _session_hash(conn, session_id): session = conn.execute( """ SELECT start_time, end_time FROM capture_sessions WHERE id=? """, (session_id,), ).fetchone() if session is None: raise WifitripError( f"Session {session_id} does not exist." ) gps_rows = conn.execute( """ SELECT timestamp, latitude, longitude FROM gps_track WHERE session_id=? ORDER BY sequence_no """, (session_id,), ).fetchall() handshake_rows = conn.execute( """ SELECT access_points.bssid, handshakes.type, handshakes.hash22000, handshakes.message_pair, handshakes.captured_at FROM handshakes JOIN access_points ON access_points.id = handshakes.access_point_id WHERE handshakes.session_id=? ORDER BY access_points.bssid, handshakes.type, handshakes.hash22000, handshakes.message_pair, handshakes.captured_at """, (session_id,), ).fetchall() handshake_data = [ [ row["bssid"], row["type"], row["hash22000"], row["message_pair"], row["captured_at"], ] for row in handshake_rows ] if gps_rows: gps_data = [ [ row["timestamp"], row["latitude"], row["longitude"], ] for row in gps_rows ] canonical = [ "GPS_SESSION_V1", gps_data, handshake_data, ] else: observation_rows = conn.execute( """ SELECT observations.observed_at, access_points.bssid FROM access_point_observations AS observations JOIN access_points ON access_points.id = observations.access_point_id WHERE observations.session_id=? ORDER BY observations.observed_at, access_points.bssid """, (session_id,), ).fetchall() observation_data = [ [ row["observed_at"], row["bssid"], ] for row in observation_rows ] canonical = [ "NON_GPS_SESSION_V1", session["start_time"], session["end_time"], observation_data, handshake_data, ] return hashlib.sha256( _json_bytes(canonical) ).hexdigest() def _default_export_path(): timestamp = datetime.now( timezone.utc ).strftime( "%Y%m%d-%H%M%S" ) return Path.cwd() / ( "wifi-gps-mapper-trips-" f"{timestamp}.wifitrip" ) def _zip_write_json(archive, filename, value): archive.writestr( filename, _json_bytes(value), ) def export_wifitrip(conn, output_path=None): started = time.monotonic() if output_path is None: output_path = _default_export_path() output_path = Path(output_path) if output_path.suffix.lower() != ".wifitrip": output_path = output_path.with_suffix( ".wifitrip" ) output_path.parent.mkdir( parents=True, exist_ok=True, ) sessions = conn.execute( """ SELECT id, start_time, end_time, session_hash, session_hash_version FROM capture_sessions ORDER BY id """ ).fetchall() session_by_id = {} exported_sessions = [] for row in sessions: session_id = row["id"] session_hash = row["session_hash"] hash_version = row[ "session_hash_version" ] if not session_hash or hash_version is None: raise WifitripError( "Session " f"{session_id} has no session hash." ) if hash_version != SESSION_HASH_VERSION: raise WifitripError( "Session " f"{session_id} has unsupported session hash " f"version {hash_version}." ) _validate_hash( session_hash, f"session {session_id} hash", ) calculated_hash = _session_hash( conn, session_id, ) if calculated_hash != session_hash: raise WifitripError( "Session " f"{session_id} hash verification failed: " f"expected {session_hash}, " f"calculated {calculated_hash}" ) gps_count = conn.execute( """ SELECT COUNT(*) FROM gps_track WHERE session_id=? """, (session_id,), ).fetchone()[0] session_type = ( "gps" if gps_count else "non_gps" ) session_record = { "session_hash": session_hash, "session_hash_version": hash_version, "start_time": row["start_time"], "end_time": row["end_time"], "session_type": session_type, } session_by_id[session_id] = session_record exported_sessions.append( session_record ) session_ids = list(session_by_id) if not session_ids: raise WifitripError( "No capture sessions available for export." ) placeholders = ",".join( "?" for _ in session_ids ) gps_rows = conn.execute( f""" SELECT gps_track.session_id, gps_track.sequence_no, gps_track.timestamp, gps_track.latitude, gps_track.longitude, gps_track.speed FROM gps_track WHERE gps_track.session_id IN ( {placeholders} ) ORDER BY gps_track.session_id, gps_track.sequence_no """, session_ids, ).fetchall() gps_tracks = [ { "session_hash": session_by_id[ row["session_id"] ]["session_hash"], "sequence_no": row["sequence_no"], "timestamp": row["timestamp"], "latitude": row["latitude"], "longitude": row["longitude"], "speed": row["speed"], } for row in gps_rows ] observation_rows = conn.execute( f""" SELECT observations.session_id, access_points.bssid, observations.observed_at, observations.latitude, observations.longitude, observations.speed, observations.rssi, observations.channel, observations.frequency, observations.essid, observations.encryption, observations.cipher, observations.akm, observations.country, observations.id FROM access_point_observations AS observations JOIN access_points ON access_points.id = observations.access_point_id WHERE observations.session_id IN ( {placeholders} ) ORDER BY observations.session_id, observations.observed_at, observations.id """, session_ids, ).fetchall() observations = [ { "session_hash": session_by_id[ row["session_id"] ]["session_hash"], "bssid": row["bssid"], "observed_at": row["observed_at"], "latitude": row["latitude"], "longitude": row["longitude"], "speed": row["speed"], "rssi": row["rssi"], "channel": row["channel"], "frequency": row["frequency"], "essid": row["essid"], "encryption": row["encryption"], "cipher": row["cipher"], "akm": row["akm"], "country": row["country"], } for row in observation_rows ] handshake_rows = conn.execute( f""" SELECT handshakes.id, handshakes.session_id, access_points.bssid, handshakes.type, handshakes.hash22000, handshakes.message_pair, handshakes.captured_at, handshakes.latitude, handshakes.longitude, handshakes.speed FROM handshakes JOIN access_points ON access_points.id = handshakes.access_point_id WHERE handshakes.session_id IN ( {placeholders} ) ORDER BY handshakes.session_id, handshakes.id """, session_ids, ).fetchall() handshake_key_by_id = {} handshake_records = {} handshakes = [] for row in handshake_rows: session_hash = session_by_id[ row["session_id"] ]["session_hash"] records = handshake_records.setdefault( session_hash, [], ) handshake_key = ( f"h{len(records) + 1:06d}" ) handshake_key_by_id[ row["id"] ] = ( session_hash, handshake_key, ) records.append( handshake_key ) handshakes.append( { "session_hash": session_hash, "handshake_key": handshake_key, "bssid": row["bssid"], "type": row["type"], "hash22000": row["hash22000"], "message_pair": row["message_pair"], "captured_at": row["captured_at"], "latitude": row["latitude"], "longitude": row["longitude"], "speed": row["speed"], } ) session_ap_ids = {} for row in observation_rows: session_ap_ids.setdefault( row["session_id"], set(), ).add( row["bssid"] ) for row in handshake_rows: session_ap_ids.setdefault( row["session_id"], set(), ).add( row["bssid"] ) credentials_rows = conn.execute( """ SELECT credentials.id, credentials.access_point_id, credentials.handshake_id, access_points.bssid, credentials.password, credentials.source, credentials.created_at, credentials.verified, handshakes.session_id AS handshake_session_id FROM credentials JOIN access_points ON access_points.id = credentials.access_point_id LEFT JOIN handshakes ON handshakes.id = credentials.handshake_id ORDER BY credentials.id """ ).fetchall() credentials = [] for row in credentials_rows: if row["handshake_session_id"] is not None: session_id = row[ "handshake_session_id" ] if session_id not in session_by_id: continue session_hash = session_by_id[ session_id ]["session_hash"] handshake_ref = ( handshake_key_by_id.get( row["handshake_id"] ) ) handshake_key = ( handshake_ref[1] if handshake_ref else None ) credentials.append( { "session_hash": session_hash, "bssid": row["bssid"], "handshake_key": handshake_key, "password": row["password"], "source": row["source"], "created_at": row["created_at"], "verified": row["verified"], } ) continue for session_id, bssids in ( session_ap_ids.items() ): if row["bssid"] not in bssids: continue if session_id not in session_by_id: continue credentials.append( { "session_hash": session_by_id[ session_id ]["session_hash"], "bssid": row["bssid"], "handshake_key": None, "password": row["password"], "source": row["source"], "created_at": row["created_at"], "verified": row["verified"], } ) bssids = set() for observation in observations: bssids.add( observation["bssid"] ) for handshake in handshakes: bssids.add( handshake["bssid"] ) for credential in credentials: bssids.add( credential["bssid"] ) access_points = [ { "bssid": bssid } for bssid in sorted(bssids) ] manifest = { "format": WIFITRIP_FORMAT, "format_version": WIFITRIP_VERSION, "created_at": time.strftime( "%Y-%m-%dT%H:%M:%SZ", time.gmtime(), ), "sessions": len( exported_sessions ), "session_hash_versions": [ SESSION_HASH_VERSION ], } logger.info( "Starting .wifitrip export: sessions=%d file=%s", len(exported_sessions), output_path, ) fd, temporary_name = tempfile.mkstemp( prefix=".wifitrip-", suffix=".tmp", dir=str(output_path.parent), ) os.close(fd) temporary_path = Path( temporary_name ) try: with zipfile.ZipFile( temporary_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6, ) as archive: _zip_write_json( archive, "manifest.json", manifest, ) _zip_write_json( archive, "sessions.json", exported_sessions, ) _zip_write_json( archive, "gps_tracks.json", gps_tracks, ) _zip_write_json( archive, "access_points.json", access_points, ) _zip_write_json( archive, "observations.json", observations, ) _zip_write_json( archive, "handshakes.json", handshakes, ) _zip_write_json( archive, "credentials.json", credentials, ) os.replace( temporary_path, output_path, ) except Exception: try: temporary_path.unlink() except FileNotFoundError: pass raise size = output_path.stat().st_size duration = ( time.monotonic() - started ) result = { "sessions": len( exported_sessions ), "access_points": len( access_points ), "gps_points": len( gps_tracks ), "observations": len( observations ), "handshakes": len( handshakes ), "credentials": len( credentials ), "file": str(output_path), "size": size, "duration": duration, } logger.info( "Completed .wifitrip export: " "sessions=%d observations=%d handshakes=%d " "credentials=%d file=%s size=%d duration=%.3fs", result["sessions"], result["observations"], result["handshakes"], result["credentials"], output_path, size, duration, ) return result def _get_or_create_import_ap( conn, bssid, created_access_points, ): row = conn.execute( """ SELECT id FROM access_points WHERE bssid=? """, (bssid,), ).fetchone() if row is not None: return row["id"] cursor = conn.execute( """ INSERT INTO access_points ( bssid, times_seen ) VALUES (?, 0) """, (bssid,), ) access_point_id = cursor.lastrowid created_access_points.add( access_point_id ) return access_point_id def _update_imported_ap_aggregates( conn, access_point_id, imported_observation_count, imported_first_seen, imported_last_seen, latest_observation, ): if imported_observation_count == 0: return existing = conn.execute( """ SELECT first_seen, last_seen, times_seen FROM access_points WHERE id=? """, (access_point_id,), ).fetchone() if existing is None: raise WifitripError( f"Access point {access_point_id} does not exist." ) first_seen = existing["first_seen"] if ( first_seen is None or imported_first_seen < first_seen ): first_seen = imported_first_seen last_seen = existing["last_seen"] latest_is_global_latest = ( last_seen is None or imported_last_seen >= last_seen ) if ( last_seen is None or imported_last_seen > last_seen ): last_seen = imported_last_seen if latest_is_global_latest: conn.execute( """ UPDATE access_points SET first_seen=?, last_seen=?, times_seen=COALESCE(times_seen, 0) + ?, last_rssi=?, last_latitude=?, last_longitude=?, last_speed=?, channel=COALESCE(?, channel), frequency=COALESCE(?, frequency), essid=COALESCE(?, essid), encryption=COALESCE(?, encryption), cipher=COALESCE(?, cipher), akm=COALESCE(?, akm), country=COALESCE(?, country) WHERE id=? """, ( first_seen, last_seen, imported_observation_count, latest_observation["rssi"], latest_observation["latitude"], latest_observation["longitude"], latest_observation["speed"], latest_observation["channel"], latest_observation["frequency"], latest_observation["essid"], latest_observation["encryption"], latest_observation["cipher"], latest_observation["akm"], latest_observation["country"], access_point_id, ), ) else: conn.execute( """ UPDATE access_points SET first_seen=?, last_seen=?, times_seen=COALESCE(times_seen, 0) + ? WHERE id=? """, ( first_seen, last_seen, imported_observation_count, access_point_id, ), ) def _session_id_by_hash( conn, session_hash, session_hash_version, ): row = conn.execute( """ SELECT id FROM capture_sessions WHERE session_hash=? AND session_hash_version=? LIMIT 1 """, ( session_hash, session_hash_version, ), ).fetchone() if row is None: return None return row["id"] def import_wifitrip(conn, package_path): started = time.monotonic() logger.info( "Starting .wifitrip import: file=%s", package_path, ) package = _read_package( package_path ) manifest = package["manifest.json"] sessions = package["sessions.json"] gps_tracks = package["gps_tracks.json"] observations = package["observations.json"] handshakes = package["handshakes.json"] credentials = package["credentials.json"] gps_by_session = {} observations_by_session = {} handshakes_by_session = {} credentials_by_session = {} for point in gps_tracks: gps_by_session.setdefault( point["session_hash"], [], ).append(point) for observation in observations: observations_by_session.setdefault( observation["session_hash"], [], ).append(observation) for handshake in handshakes: handshakes_by_session.setdefault( handshake["session_hash"], [], ).append(handshake) for credential in credentials: credentials_by_session.setdefault( credential["session_hash"], [], ).append(credential) result = { "file": str(package_path), "sessions": len(sessions), "imported": 0, "duplicates": 0, "failed": 0, "failed_sessions": [], } conn.execute( "PRAGMA foreign_keys=ON" ) if conn.in_transaction: raise WifitripError( "Database connection already has an active transaction." ) for session in sessions: session_hash = session[ "session_hash" ] hash_version = session[ "session_hash_version" ] existing_id = _session_id_by_hash( conn, session_hash, hash_version, ) if existing_id is not None: result["duplicates"] += 1 logger.info( "Skipping duplicate .wifitrip session: " "hash=%s local_id=%s", session_hash, existing_id, ) continue try: conn.execute( "BEGIN" ) cursor = conn.execute( """ INSERT INTO capture_sessions ( start_time, end_time, capture_file, session_hash, session_hash_version ) VALUES (?, ?, ?, ?, ?) """, ( session["start_time"], session["end_time"], f"wifitrip:{session_hash}", session_hash, hash_version, ), ) local_session_id = cursor.lastrowid created_access_points = set() ap_ids = {} session_observations = ( observations_by_session.get( session_hash, [], ) ) session_handshakes = ( handshakes_by_session.get( session_hash, [], ) ) session_credentials = ( credentials_by_session.get( session_hash, [], ) ) bssids = set() for observation in session_observations: bssids.add( observation["bssid"] ) for handshake in session_handshakes: bssids.add( handshake["bssid"] ) for credential in session_credentials: bssids.add( credential["bssid"] ) for bssid in sorted(bssids): ap_ids[bssid] = ( _get_or_create_import_ap( conn, bssid, created_access_points, ) ) for point in gps_by_session.get( session_hash, [], ): conn.execute( """ INSERT INTO gps_track ( session_id, sequence_no, latitude, longitude, speed, timestamp ) VALUES (?, ?, ?, ?, ?, ?) """, ( local_session_id, point["sequence_no"], point["latitude"], point["longitude"], point["speed"], point["timestamp"], ), ) imported_observations_by_ap = {} for observation in session_observations: access_point_id = ap_ids[ observation["bssid"] ] conn.execute( """ INSERT INTO access_point_observations ( access_point_id, session_id, observed_at, latitude, longitude, speed, rssi, channel, frequency, essid, encryption, cipher, akm, country ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) """, ( access_point_id, local_session_id, observation["observed_at"], observation["latitude"], observation["longitude"], observation["speed"], observation["rssi"], observation["channel"], observation["frequency"], observation["essid"], observation["encryption"], observation["cipher"], observation["akm"], observation["country"], ), ) imported_observations_by_ap.setdefault( access_point_id, [], ).append(observation) for access_point_id, imported_observations in ( imported_observations_by_ap.items() ): imported_first_seen = min( observation["observed_at"] for observation in imported_observations ) imported_last_seen = max( observation["observed_at"] for observation in imported_observations ) latest_observation = max( imported_observations, key=lambda observation: ( observation["observed_at"], ), ) _update_imported_ap_aggregates( conn, access_point_id, len(imported_observations), imported_first_seen, imported_last_seen, latest_observation, ) handshake_ids = {} for handshake in session_handshakes: access_point_id = ap_ids[ handshake["bssid"] ] cursor = conn.execute( """ INSERT INTO handshakes ( access_point_id, session_id, type, hash22000, message_pair, captured_at, latitude, longitude, speed ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ? ) """, ( access_point_id, local_session_id, handshake["type"], handshake["hash22000"], handshake["message_pair"], handshake["captured_at"], handshake["latitude"], handshake["longitude"], handshake["speed"], ), ) handshake_ids[ handshake["handshake_key"] ] = cursor.lastrowid if handshake["type"] == "PMKID": conn.execute( """ UPDATE access_points SET has_pmkid=1 WHERE id=? """, (access_point_id,), ) else: conn.execute( """ UPDATE access_points SET has_handshake=1 WHERE id=? """, (access_point_id,), ) calculated_hash = _session_hash( conn, local_session_id, ) if calculated_hash != session_hash: raise WifitripError( "Session hash verification failed: " f"expected {session_hash}, " f"calculated {calculated_hash}" ) for credential in session_credentials: access_point_id = ap_ids[ credential["bssid"] ] handshake_id = None if credential[ "handshake_key" ] is not None: handshake_id = handshake_ids[ credential[ "handshake_key" ] ] existing_credential = conn.execute( """ SELECT id FROM credentials WHERE access_point_id=? AND handshake_id IS ? AND password=? LIMIT 1 """, ( access_point_id, handshake_id, credential["password"], ), ).fetchone() if existing_credential is not None: continue conn.execute( """ INSERT INTO credentials ( access_point_id, handshake_id, password, source, created_at, verified ) VALUES (?, ?, ?, ?, ?, ?) """, ( access_point_id, handshake_id, credential["password"], credential["source"], credential["created_at"], credential["verified"], ), ) conn.execute( """ UPDATE access_points SET is_cracked=1 WHERE id=? """, (access_point_id,), ) conn.commit() result["imported"] += 1 logger.info( "Imported .wifitrip session: " "hash=%s local_id=%s", session_hash, local_session_id, ) except Exception as exc: conn.rollback() result["failed"] += 1 result["failed_sessions"].append( { "session_hash": session_hash, "error": str(exc), } ) logger.exception( "Failed to import .wifitrip session: " "hash=%s", session_hash, ) result["duration"] = ( time.monotonic() - started ) logger.info( "Completed .wifitrip import: " "sessions=%d imported=%d duplicates=%d " "failed=%d file=%s duration=%.3fs", result["sessions"], result["imported"], result["duplicates"], result["failed"], package_path, result["duration"], ) return result def validate_wifitrip(package_path): """ Полная проверка .wifitrip без изменения БД. Возвращает краткую информацию о пакете. """ package = _read_package( package_path ) manifest = package["manifest.json"] return { "valid": True, "format": manifest["format"], "format_version": manifest[ "format_version" ], "sessions": len( package["sessions.json"] ), "access_points": len( package["access_points.json"] ), "gps_points": len( package["gps_tracks.json"] ), "observations": len( package["observations.json"] ), "handshakes": len( package["handshakes.json"] ), "credentials": len( package["credentials.json"] ), "session_hash_versions": manifest[ "session_hash_versions" ], }