from __future__ import annotations import argparse import hashlib import json import os import re import secrets import struct import subprocess import time from datetime import UTC, datetime from pathlib import Path from typing import Any from pcapng import FileScanner from pcapng.blocks import EnhancedPacket from preflight import ( pymobiledevice3_binary, pymobiledevice3_version, usb_device_count, ) REPO_ROOT = Path(__file__).resolve().parents[4] SESSION_ROOT = REPO_ROOT / "sessions" / "iphone-k1-observation" SAFE_LABEL = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$") def utc_now() -> str: return datetime.now(UTC).isoformat() def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def git_output(*args: str) -> bytes: completed = subprocess.run( ["git", *args], cwd=REPO_ROOT, check=True, capture_output=True, ) return completed.stdout def source_snapshot() -> dict[str, Any]: revision = git_output("rev-parse", "HEAD").decode().strip() status = git_output("status", "--porcelain=v1", "--untracked-files=all") dirty = bool(status.strip()) digest = hashlib.sha256() digest.update(revision.encode()) digest.update(b"\0") digest.update(status) if dirty: digest.update(git_output("diff", "--binary", "HEAD")) untracked = git_output("ls-files", "--others", "--exclude-standard", "-z") for raw_name in filter(None, untracked.split(b"\0")): relative = raw_name.decode("utf-8", errors="surrogateescape") path = REPO_ROOT / relative digest.update(raw_name) digest.update(b"\0") if path.is_file(): digest.update(sha256_file(path).encode()) return { "git_revision": revision, "source_tree_dirty": dirty, "source_tree_fingerprint_sha256": digest.hexdigest(), } def terminate(process: subprocess.Popen[bytes]) -> int: if process.poll() is None: process.terminate() try: process.wait(timeout=10) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=5) return int(process.returncode or 0) def artifact_record(path: Path, kind: str, session_dir: Path) -> dict[str, Any] | None: if not path.is_file() or path.stat().st_size == 0: return None path.chmod(0o600) return { "kind": kind, "relative_path": str(path.relative_to(session_dir)), "size_bytes": path.stat().st_size, "sha256": sha256_file(path), } def convert_pcapng_to_pcap(source: Path, destination: Path) -> int: """Create a classic Ethernet PCAP that macOS's bundled tcpdump can read.""" packet_count = 0 with source.open("rb") as source_stream, destination.open("xb") as destination_stream: destination_stream.write( struct.pack( "= 1_000_000: seconds += 1 microseconds -= 1_000_000 destination_stream.write( struct.pack( " argparse.Namespace: parser = argparse.ArgumentParser( description="Bounded, redacted USB iPhone capture for the Mission Core K1 lab" ) parser.add_argument("--duration", type=int, default=120, help="capture seconds (5..600)") parser.add_argument("--label", default="lixelgo-observe", help="safe session label") parser.add_argument("--process", help="optional iOS process filter for network capture") parser.add_argument("--interface", help="optional iOS interface filter for network capture") parser.add_argument( "--bluetooth", action="store_true", help="also capture iPhone Bluetooth HCI; requires Apple's logging profile", ) parser.add_argument( "--acknowledge-ios-bluetooth-profile", action="store_true", help="confirm the operator already installed Apple's Bluetooth logging profile", ) parser.add_argument( "--allow-dirty", action="store_true", help="record an explicit dirty-tree fingerprint instead of refusing the capture", ) return parser.parse_args() def main() -> int: args = parse_args() if not 5 <= args.duration <= 600: raise SystemExit("--duration must be between 5 and 600 seconds") if not SAFE_LABEL.fullmatch(args.label): raise SystemExit("--label must use lowercase ASCII letters, digits, and hyphens") if args.bluetooth and not args.acknowledge_ios_bluetooth_profile: raise SystemExit( "Bluetooth capture requires --acknowledge-ios-bluetooth-profile; " "the script never installs the profile" ) os.umask(0o077) binary = pymobiledevice3_binary() if usb_device_count(binary) != 1: raise SystemExit("exactly one trusted USB iPhone must be connected") snapshot = source_snapshot() if snapshot["source_tree_dirty"] and not args.allow_dirty: raise SystemExit( "repository is dirty; commit the milestone or pass --allow-dirty " "to record its fingerprint" ) session_id = ( datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + f"_{args.label}_{secrets.token_hex(2)}" ) session_dir = SESSION_ROOT / session_id capture_dir = session_dir / "captures" capture_dir.mkdir(parents=True, mode=0o700) network_pcapng_path = capture_dir / "iphone-network.pcapng" network_pcap_path = capture_dir / "iphone-network.pcap" bluetooth_path = capture_dir / "iphone-bluetooth.pcapng" stderr_path = session_dir / "capture.stderr.log" manifest_path = session_dir / "manifest.redacted.json" manifest: dict[str, Any] = { "schema": "missioncore.xgrids-k1/iphone-observation-manifest/v1alpha1", "session_id": session_id, "status": "starting", "started_at_utc": utc_now(), "started_monotonic_ns": time.monotonic_ns(), "requested_duration_seconds": args.duration, "capture_backend": "pymobiledevice3-9.36.0/com.apple.pcapd", "pymobiledevice3_version": pymobiledevice3_version(binary), "network_capture": True, "bluetooth_capture": args.bluetooth, "network_process_filter": args.process, "network_interface_filter": args.interface, "device_identity_recorded": False, "credentials_recorded_in_manifest": False, "source": snapshot, "artifacts": [], } manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") manifest_path.chmod(0o600) network_command = [binary, "--no-color", "pcap", "--out", str(network_pcapng_path)] if args.process: network_command.extend(["--process", args.process]) if args.interface: network_command.extend(["--interface", args.interface]) commands = [("network", network_command)] if args.bluetooth: commands.append( ( "bluetooth", [binary, "--no-color", "btlogger", str(bluetooth_path), "--format", "pcapng"], ) ) processes: list[tuple[str, subprocess.Popen[bytes]]] = [] exit_codes: dict[str, int] = {} interrupted = False failure: str | None = None with stderr_path.open("wb") as error_log: stderr_path.chmod(0o600) try: for name, command in commands: process = subprocess.Popen( command, cwd=REPO_ROOT, stdout=subprocess.DEVNULL, stderr=error_log, ) processes.append((name, process)) time.sleep(1) early = [name for name, process in processes if process.poll() is not None] if early: raise RuntimeError(f"capture service exited early: {', '.join(early)}") deadline = time.monotonic() + args.duration while time.monotonic() < deadline: ended = [name for name, process in processes if process.poll() is not None] if ended: raise RuntimeError(f"capture service stopped unexpectedly: {', '.join(ended)}") time.sleep(min(0.25, deadline - time.monotonic())) except KeyboardInterrupt: interrupted = True except RuntimeError as exc: failure = str(exc) finally: for name, process in processes: exit_codes[name] = terminate(process) network_packet_count: int | None = None if failure is None: try: network_packet_count = convert_pcapng_to_pcap( network_pcapng_path, network_pcap_path, ) if network_packet_count == 0: failure = "network capture validation found no packets" except Exception as exc: # noqa: BLE001 - third-party parser errors vary by version failure = f"network capture validation failed: {type(exc).__name__}" artifacts = [ artifact_record(network_pcapng_path, "iphone-network-pcapng-raw", session_dir), artifact_record(network_pcap_path, "iphone-network-pcap-compatible", session_dir), artifact_record(bluetooth_path, "iphone-bluetooth-hci-pcapng", session_dir), artifact_record(stderr_path, "capture-stderr", session_dir), ] manifest.update( { "status": "failed" if failure else "completed", "interrupted_by_operator": interrupted, "failure": failure, "ended_at_utc": utc_now(), "ended_monotonic_ns": time.monotonic_ns(), "process_exit_codes": exit_codes, "network_packet_count": network_packet_count, "artifacts": [item for item in artifacts if item is not None], } ) manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") manifest_path.chmod(0o600) print(f"session: {session_dir}") print(f"status: {manifest['status']}") return 1 if failure else 0 if __name__ == "__main__": raise SystemExit(main())