Files
wifi-gps-mapper-public/reports/wifitrip.py
T
2026-10-06 21:08:12 +03:00

2112 lines
52 KiB
Python

#!/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"
],
}