feat(perception): integrate calibrated operator pipeline
Add calibrated K1 projection, recorded and near-live perception qualification, unified Rerun operator layers, bounded replay admission, audited viewer controls, worker experiments, and lab evidence.
This commit is contained in:
+47
-11
@@ -20,11 +20,21 @@ The plugin owns:
|
||||
[`profiles/`](profiles/), with strict evidence flags and a fail-closed loader;
|
||||
- BLE discovery hints and K1 GATT metadata;
|
||||
- Bridge and Direct Connect through the reviewed firmware-3 BLE Wi-Fi
|
||||
provisioning frame, plus a one-shot macOS CoreWLAN Quick Connect adapter;
|
||||
- K1 LAN status and private-address validation;
|
||||
provisioning frame, plus the reviewed Quick Connect AP-enable frame and
|
||||
one-shot host-profile association adapter;
|
||||
- read-only refresh of the selected K1's session-scoped DHCP address before a
|
||||
new LAN operation; address changes rotate the device session and IP is never
|
||||
treated as device identity;
|
||||
- subscribe-only data MQTT transport plus a separately bounded canonical
|
||||
application-control transport;
|
||||
- native `.k1mqtt` capture;
|
||||
- the read-only factory-calibration transport: one live `DeviceInfo` binding,
|
||||
command-5 reads of exactly `camera.yaml` and
|
||||
`extrinsic_camera_lidar.yaml`, and append-only private source snapshots; file
|
||||
write and arbitrary paths are unavailable;
|
||||
- safe exact-schema normalization of KB4 intrinsics, the main-camera 800x600
|
||||
resize and explicit `T_camera_N_from_lidar` matrices, with firmware-verified
|
||||
`sensor.camera.left -> camera_0` and `sensor.camera.right -> camera_1` binding;
|
||||
- firmware-scoped protobuf/LZ4 and legacy codecs;
|
||||
- normalization of K1 point cloud and pose, plus raw-only preservation of the
|
||||
still-undecoded status and heartbeat channels;
|
||||
@@ -89,11 +99,39 @@ Plugin v0.6.0 retains the physically accepted v0.5.0 control transport and adds
|
||||
the connection matrix behind the existing explicit `network.provision` action.
|
||||
Bridge remains the default. Direct Connect sends the same single reviewed
|
||||
99-byte frame with credentials for an already-running controller hotspot. Quick
|
||||
Connect sends no BLE write: a short-lived Swift/CoreWLAN helper receives the
|
||||
operator-entered K1 AP credentials only over stdin, performs at most one scan
|
||||
and one association, and never receives an automatic retry. Mission Core then
|
||||
uses the observed K1 AP data-plane address. Automatic extraction of AP
|
||||
credentials from undocumented BLE data is deliberately not implemented.
|
||||
Connect accepts no browser/API credential: it sends one reviewed fixed 100-byte
|
||||
AP-enable frame to the selected K1, waits up to 15 seconds for the canonical
|
||||
byte-51 AP-ready flag, and keeps that BLE session alive while the macOS adapter
|
||||
performs bounded exact-SSID CoreWLAN discovery and one association. Credentials
|
||||
are resolved by a preinstalled exact `3.0.2` firmware provider. Its optional laboratory importer
|
||||
validates the reviewed official archive, extracts the single AP declaration and
|
||||
installs firmware-scoped material in the OS secure store. The macOS helper then
|
||||
materializes the selected device profile entirely inside Keychain before any
|
||||
BLE write. The secret never enters the browser, API, argv, logs or evidence;
|
||||
the importer's short-lived mutable buffer is zeroized after the stdin handoff.
|
||||
There is no automatic BLE-write or association retry. A clean host cannot
|
||||
obtain the provider from BLE and the product does not download firmware during
|
||||
connection. Windows/Linux Quick Connect adapters are not planned while that
|
||||
bootstrap remains unresolved. Mission Core then uses the
|
||||
reviewed K1 AP data-plane address. The
|
||||
99-byte station-provisioning payload and 100-byte
|
||||
AP-enable payload share `7f01` but remain distinct exact reviewed frames; neither
|
||||
is a credential-read command.
|
||||
|
||||
For retained laboratory reproduction only, an operator can install the reviewed
|
||||
source on a macOS host without displaying the credential:
|
||||
|
||||
```bash
|
||||
uv run k1link authority import-k1-fw302-ap \
|
||||
--firmware /private/path/to/the-official-3.0.2-archive.tar \
|
||||
--confirm-reviewed-firmware
|
||||
```
|
||||
|
||||
The 2026-07-20 UI run physically accepted the prepared-host path: one BLE discovery,
|
||||
one AP-enable write, AP-ready, one CoreWLAN association, control bootstrap and
|
||||
the normal acquisition lifecycle. It does not prove that the current build is
|
||||
portable to a clean Mac. Bridge/direct-LAN remains the default and continuing
|
||||
product route; Quick Connect stays available as laboratory functionality.
|
||||
|
||||
Plugin v0.5.0 installed the reviewed application-control transport behind
|
||||
explicit plugin actions.
|
||||
@@ -105,10 +143,8 @@ next reviewed action may be requested; it never schedules a device command by
|
||||
elapsed time. START waits for bound `SCANNING + project + init_ready` before
|
||||
operations 13–14. STOP is separately permitted, never retried, and keeps the same socket until K1
|
||||
reports unbound READY. That protocol state automatically completes local sealing;
|
||||
stable green is physical corroboration rather than a second UI gate. The
|
||||
admin CLI still provisions the fixed Keychain item through Apple's hidden
|
||||
prompt without accepting the private value as an argument. No physical command
|
||||
is emitted merely by loading the plugin, opening the page, navigating, polling
|
||||
stable green is physical corroboration rather than a second UI gate. No
|
||||
physical command is emitted merely by loading the plugin, opening the page, navigating, polling
|
||||
state or running repository tests. The full v0.5.0 START/live/STOP/save path is
|
||||
physically accepted on the reviewed K1/A4/FW 3.0.2 unit.
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ export interface XgridsAcquisition {
|
||||
cleanup_pending?: boolean;
|
||||
requested_streams: string[];
|
||||
target_host: string;
|
||||
duration_seconds: number;
|
||||
duration_seconds: number | null;
|
||||
evidence_policy: "required" | "best-effort" | "disabled";
|
||||
state: AcquisitionState;
|
||||
state_revision: number;
|
||||
@@ -229,6 +229,11 @@ export interface XgridsK1Metrics {
|
||||
frame_rate_hz?: number | null;
|
||||
point_count?: number | null;
|
||||
dropped_preview_frames?: number | null;
|
||||
ai_end_to_end_ms?: number | null;
|
||||
ai_end_to_end_p95_ms?: number | null;
|
||||
ai_frame_rate_hz?: number | null;
|
||||
ai_dropped_frames?: number | null;
|
||||
ai_stale_ms?: number | null;
|
||||
device_elapsed_seconds?: number | null;
|
||||
device_route_distance_meters?: number | null;
|
||||
device_speed_meters_per_second?: number | null;
|
||||
@@ -329,8 +334,8 @@ export interface CompatibilityAttestation {
|
||||
|
||||
export interface ConnectRequest {
|
||||
device_id: string;
|
||||
ssid: string;
|
||||
password: string;
|
||||
ssid?: string;
|
||||
password?: string;
|
||||
connection_mode: "bridge" | "quick-connect" | "direct-connect";
|
||||
compatibility_attestation: CompatibilityAttestation;
|
||||
operation_id?: string;
|
||||
|
||||
@@ -23,8 +23,8 @@ import type { XgridsK1Controller } from "../runtimeContext";
|
||||
|
||||
const connectionCopy: Record<ConnectionMode, {
|
||||
stepTitle: string;
|
||||
ssidLabel: string;
|
||||
ssidPlaceholder: string;
|
||||
ssidLabel?: string;
|
||||
ssidPlaceholder?: string;
|
||||
buttonLabel: string;
|
||||
safetyNote: string;
|
||||
}> = {
|
||||
@@ -36,18 +36,16 @@ const connectionCopy: Record<ConnectionMode, {
|
||||
safetyNote: "K1 получит реквизиты существующей сети одним рассмотренным BLE-запросом без автоматического повтора.",
|
||||
},
|
||||
"quick-connect": {
|
||||
stepTitle: "Подключитесь к точке доступа K1",
|
||||
ssidLabel: "Название точки доступа K1",
|
||||
ssidPlaceholder: "SSID сканера, например XGR-…",
|
||||
buttonLabel: "Подключить этот Mac к K1",
|
||||
safetyNote: "Введите SSID и пароль точки доступа вашего K1. Mac сменит текущую Wi‑Fi сеть одним CoreWLAN-запросом; недокументированный BLE-секрет не читается и K1 не получает BLE-запись.",
|
||||
stepTitle: "Включите точку доступа K1 и подключитесь к ней",
|
||||
buttonLabel: "Включить точку K1 и подключиться",
|
||||
safetyNote: "Mission Core сначала проверит локальный device-scoped профиль выбранного K1. Если профиль отсутствует, операция остановится до BLE-записи. После preflight Mission Core отправит один рассмотренный AP-enable кадр и найдёт точный SSID выбранного устройства.",
|
||||
},
|
||||
"direct-connect": {
|
||||
stepTitle: "Подключите K1 к хотспоту контроллера",
|
||||
ssidLabel: "Название хотспота контроллера",
|
||||
ssidPlaceholder: "SSID управляющего устройства",
|
||||
buttonLabel: "Подключить K1 к хотспоту",
|
||||
safetyNote: "Хотспот должен быть уже включён, а этот Mac — иметь к нему маршрут. K1 получит его реквизиты одним рассмотренным BLE-запросом.",
|
||||
safetyNote: "Хотспот должен быть уже включён, а управляющее устройство — иметь к нему маршрут. K1 получит его реквизиты одним рассмотренным BLE-запросом.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -131,7 +129,8 @@ export function K1ProvisioningPipeline({
|
||||
const provisioningIntentRef = useRef<string | null>(null);
|
||||
const devices = state?.devices ?? [];
|
||||
const isBusy = pendingAction !== null;
|
||||
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
|
||||
const credentialsReady = connectionMode === "quick-connect"
|
||||
|| (ssid.trim().length > 0 && password.length > 0);
|
||||
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
||||
const modeCopy = connectionCopy[connectionMode];
|
||||
const selectedModeConnected = Boolean(
|
||||
@@ -170,10 +169,12 @@ export function K1ProvisioningPipeline({
|
||||
if (!canConnect) return;
|
||||
const idempotencyKey = provisioningIntentKey(provisioningIntentRef.current);
|
||||
provisioningIntentRef.current = idempotencyKey;
|
||||
const networkCredentials = connectionMode === "quick-connect"
|
||||
? {}
|
||||
: { ssid: ssid.trim(), password };
|
||||
const succeeded = await connect({
|
||||
device_id: selectedDeviceId,
|
||||
ssid: ssid.trim(),
|
||||
password,
|
||||
...networkCredentials,
|
||||
connection_mode: connectionMode,
|
||||
compatibility_attestation: profileSelectionForConnectionMode(connectionMode),
|
||||
idempotency_key: idempotencyKey,
|
||||
@@ -181,6 +182,11 @@ export function K1ProvisioningPipeline({
|
||||
if (succeeded) {
|
||||
provisioningIntentRef.current = null;
|
||||
setPassword("");
|
||||
} else if (connectionMode === "quick-connect") {
|
||||
// The backend has already persisted and reconciled the failed bounded
|
||||
// attempt. A later click is a new explicit Quick Connect intent, not an
|
||||
// automatic replay of the consumed operation key.
|
||||
provisioningIntentRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -252,15 +258,27 @@ export function K1ProvisioningPipeline({
|
||||
status={selectedModeConnected ? "Адрес получен" : "Ожидает подключения"}
|
||||
tone={selectedModeConnected ? "success" : "neutral"}
|
||||
>
|
||||
<div className="field-stack">
|
||||
<TextField label={modeCopy.ssidLabel} hint="SSID" value={ssid} onChange={(event) => { setSsid(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" spellCheck={false} placeholder={modeCopy.ssidPlaceholder} />
|
||||
<TextField label="Пароль Wi‑Fi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
|
||||
</div>
|
||||
{connectionMode === "quick-connect" ? (
|
||||
<div className="connection-summary">
|
||||
<span>Канонический путь</span>
|
||||
<strong>BLE включает AP → macOS подключает Mac к AP выбранного K1</strong>
|
||||
</div>
|
||||
) : (
|
||||
<div className="field-stack">
|
||||
<TextField label={modeCopy.ssidLabel ?? "Название сети Wi‑Fi"} hint="SSID" value={ssid} onChange={(event) => { setSsid(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" spellCheck={false} placeholder={modeCopy.ssidPlaceholder} />
|
||||
<TextField label="Пароль Wi‑Fi" hint="Только в оперативной памяти" type="password" value={password} onChange={(event) => { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
|
||||
</div>
|
||||
)}
|
||||
<div className="connection-summary"><span>Устройство</span><strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong></div>
|
||||
<Button width="full" variant="primary" icon={<Icon name="network" />} disabled={!canConnect} onClick={() => void submitConnect()}>
|
||||
{pendingAction === "connect" ? "Подключаем…" : modeCopy.buttonLabel}
|
||||
{pendingAction === "connect"
|
||||
? connectionMode === "quick-connect" ? "Включаем точку и подключаем…" : "Подключаем…"
|
||||
: modeCopy.buttonLabel}
|
||||
</Button>
|
||||
<p className="safety-note">{modeCopy.safetyNote} Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха.</p>
|
||||
<p className="safety-note">
|
||||
{modeCopy.safetyNote}
|
||||
{connectionMode === "quick-connect" ? " Это лабораторный путь для уже подготовленного хоста: credential provider должен существовать в системном хранилище заранее. На чистом Mac операция завершится до BLE-записи; браузер, API, журналы и evidence секрета не получают." : " Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха."}
|
||||
</p>
|
||||
</WizardStep>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
@@ -17,7 +17,7 @@ export const connectionModeOptions: Array<SelectOption<ConnectionMode>> = [
|
||||
{
|
||||
value: "quick-connect",
|
||||
label: "Точка доступа K1 · Quick Connect",
|
||||
description: "Этот Mac один раз подключается к Wi‑Fi сканера; K1 остаётся точкой доступа.",
|
||||
description: "Лабораторный режим: Mission Core включает AP K1 и подключает только заранее подготовленный хост. Для обычной работы используйте Bridge.",
|
||||
},
|
||||
{
|
||||
value: "direct-connect",
|
||||
|
||||
@@ -108,6 +108,10 @@ function normalizeState(
|
||||
frameRateHz: finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz),
|
||||
pointCount: finiteMetric(metrics?.point_count),
|
||||
droppedPreviewFrames: finiteMetric(metrics?.dropped_preview_frames),
|
||||
aiLatencyMs: finiteMetric(metrics?.ai_end_to_end_ms),
|
||||
aiFrameRateHz: finiteMetric(metrics?.ai_frame_rate_hz),
|
||||
aiDroppedFrames: finiteMetric(metrics?.ai_dropped_frames),
|
||||
aiStaleMs: finiteMetric(metrics?.ai_stale_ms),
|
||||
elapsedSeconds: telemetry.elapsedSeconds,
|
||||
routeDistanceMeters: telemetry.routeDistanceMeters,
|
||||
speedMetersPerSecond: telemetry.speedMetersPerSecond,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type OperatorPresenceConfirmation,
|
||||
type PrepareAcquisitionRequest,
|
||||
type ReplayRequest,
|
||||
type XgridsOperation,
|
||||
type XgridsApplicationControlPhase,
|
||||
type XgridsK1State,
|
||||
} from "./api";
|
||||
@@ -186,6 +187,34 @@ function messageFor(error: unknown): string {
|
||||
return "Запрос к локальному сервису устройства завершился ошибкой.";
|
||||
}
|
||||
|
||||
function networkProvisionFailureMessage(
|
||||
operation: XgridsOperation | null | undefined,
|
||||
): string | null {
|
||||
if (!operation || operation.status !== "failed") return null;
|
||||
const code = operation.error?.code;
|
||||
if (typeof code !== "string") return null;
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
"network-not-found":
|
||||
"Точка доступа выбранного K1 не найдена. Команда включения точки не повторялась; проверьте питание и состояние K1.",
|
||||
"credential-entry-cancelled":
|
||||
"Первичная регистрация пароля K1 отменена. Получите пароль сохранённой сети этого K1 на авторизованном устройстве и повторите подключение отдельным действием.",
|
||||
"credential-invalid":
|
||||
"Пароль точки доступа K1 имеет недопустимую длину. Получите сохранённый пароль этого K1 в LixelGO/iPhone и повторите подключение.",
|
||||
"host-wifi-operation-timeout":
|
||||
"Первичное системное подключение к K1 не было завершено вовремя. BLE-команда автоматически не повторялась; получите пароль сохранённой сети этого K1 и запустите подключение заново.",
|
||||
"profile-ssid-mismatch":
|
||||
"Сохранённый профиль относится к другому K1. Подключение остановлено без повторной команды сканеру.",
|
||||
"corewlan-error":
|
||||
"macOS не смогла подключиться к точке доступа K1. Проверьте пароль сохранённой сети этого K1; автоматического повтора не было.",
|
||||
"wifi-interface-unavailable":
|
||||
"Системный Wi-Fi-интерфейс macOS недоступен. Команда сканеру автоматически не повторялась.",
|
||||
"unsupported-platform":
|
||||
"Для этой операционной системы адаптер подключения к точке K1 ещё не реализован.",
|
||||
};
|
||||
return messages[code] ?? null;
|
||||
}
|
||||
|
||||
function measuredLatency(state: XgridsK1State | null): number | null {
|
||||
if (state?.source_mode !== "live") return null;
|
||||
const metrics = state?.metrics;
|
||||
@@ -318,7 +347,29 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
);
|
||||
}
|
||||
|
||||
const nextState = await xgridsK1Api.connect(request);
|
||||
let nextState: XgridsK1State;
|
||||
try {
|
||||
nextState = await xgridsK1Api.connect(request);
|
||||
} catch (connectError) {
|
||||
// A failed network action is terminal and persisted by the backend.
|
||||
// Re-read state only; never replay the device command. This replaces
|
||||
// an opaque HTTP 502 banner with the exact host-association outcome.
|
||||
let failedState: XgridsK1State;
|
||||
try {
|
||||
failedState = await xgridsK1Api.getState();
|
||||
} catch {
|
||||
throw connectError;
|
||||
}
|
||||
acceptState(failedState);
|
||||
const failedOperation = operationByIdempotencyKey(
|
||||
failedState,
|
||||
"network.provision",
|
||||
request.idempotency_key,
|
||||
);
|
||||
const failureMessage = networkProvisionFailureMessage(failedOperation);
|
||||
if (failureMessage) throw new ApiError(failureMessage);
|
||||
throw connectError;
|
||||
}
|
||||
const operation = operationByIdempotencyKey(
|
||||
nextState,
|
||||
"network.provision",
|
||||
@@ -334,7 +385,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
}
|
||||
return nextState;
|
||||
}),
|
||||
[run, state],
|
||||
[acceptState, run, state],
|
||||
);
|
||||
|
||||
const openApplicationControlSession = useCallback(
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Export only LixelGO preferences from an owner-authorized USB iPhone.
|
||||
|
||||
MobileBackup2 asks the host how much free space is available before it starts.
|
||||
The device estimates that requirement as if the transfer were an ordinary full
|
||||
backup, even when this client discards every payload outside one exact app
|
||||
preferences domain. This lab tool reports a synthetic capacity only inside
|
||||
this filtered process. Non-matching payload bytes are consumed without being
|
||||
written by pymobiledevice3's existing ``preserve_file`` boundary.
|
||||
|
||||
The completed raw backup exists only inside a private temporary directory. The
|
||||
tool copies the one reviewed preferences plist to an ignored mode-0600 session
|
||||
and removes the temporary backup before returning. It never prints plist
|
||||
values, the device identifier, or any credential.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import plistlib
|
||||
import secrets
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from contextlib import closing
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
SESSION_ROOT = REPO_ROOT / "sessions" / "iphone-k1-observation"
|
||||
BUNDLE_ID = "com.XGrids.LixelGo"
|
||||
APP_DOMAIN = f"AppDomain-{BUNDLE_ID}"
|
||||
PREFERENCES_PATH = f"Library/Preferences/{BUNDLE_ID}.plist"
|
||||
PREFERENCES_FILE_ID = hashlib.sha1(
|
||||
f"{APP_DOMAIN}-{PREFERENCES_PATH}".encode(),
|
||||
usedforsecurity=False,
|
||||
).hexdigest()
|
||||
SYNTHETIC_AVAILABLE_BYTES = 2 * 1024**4
|
||||
MAX_EXPORTED_PREFERENCES_BYTES = 16 * 1024 * 1024
|
||||
|
||||
|
||||
class TargetedProfileBackupError(RuntimeError):
|
||||
"""The bounded LixelGO preference export could not be completed safely."""
|
||||
|
||||
|
||||
def _is_preferences_backup_file(backup_file: Any) -> bool:
|
||||
"""Select only the exact LixelGO preferences payload or manifest row."""
|
||||
|
||||
# During transfer the hashed destination is exposed as ``file_name`` while
|
||||
# some protocol versions also expose it as ``device_name``. Manifest
|
||||
# pruning supplies the logical domain/path instead.
|
||||
for attribute in ("file_name", "device_name"):
|
||||
candidate = getattr(backup_file, attribute, None)
|
||||
if isinstance(candidate, str) and Path(candidate).name == PREFERENCES_FILE_ID:
|
||||
return True
|
||||
return (
|
||||
getattr(backup_file, "domain", None) == APP_DOMAIN
|
||||
and getattr(backup_file, "relative_path", None) == PREFERENCES_PATH
|
||||
)
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _single_device_directory(backup_root: Path) -> Path:
|
||||
candidates = [
|
||||
path
|
||||
for path in backup_root.iterdir()
|
||||
if path.is_dir() and not path.is_symlink()
|
||||
]
|
||||
if len(candidates) != 1:
|
||||
raise TargetedProfileBackupError("filtered backup did not create one device directory")
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _manifest_file_id(device_directory: Path) -> str:
|
||||
manifest_path = device_directory / "Manifest.db"
|
||||
if not manifest_path.is_file() or manifest_path.is_symlink():
|
||||
raise TargetedProfileBackupError("filtered backup has no readable Manifest.db")
|
||||
with closing(sqlite3.connect(f"file:{manifest_path}?mode=ro", uri=True)) as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT fileID FROM Files WHERE domain = ? AND relativePath = ?",
|
||||
(APP_DOMAIN, PREFERENCES_PATH),
|
||||
).fetchall()
|
||||
if len(rows) != 1 or not isinstance(rows[0][0], str):
|
||||
raise TargetedProfileBackupError("LixelGO preferences were not present in the backup")
|
||||
file_id = rows[0][0]
|
||||
if len(file_id) != 40 or any(character not in "0123456789abcdef" for character in file_id):
|
||||
raise TargetedProfileBackupError("LixelGO preferences have an invalid backup file id")
|
||||
if file_id != PREFERENCES_FILE_ID:
|
||||
raise TargetedProfileBackupError(
|
||||
"LixelGO preferences backup file id does not match the exact requested path"
|
||||
)
|
||||
return file_id
|
||||
|
||||
|
||||
def _read_preferences(device_directory: Path) -> bytes:
|
||||
manifest = plistlib.loads((device_directory / "Manifest.plist").read_bytes())
|
||||
if manifest.get("IsEncrypted") is True:
|
||||
raise TargetedProfileBackupError(
|
||||
"encrypted filtered backups are not supported by this probe"
|
||||
)
|
||||
file_id = _manifest_file_id(device_directory)
|
||||
source = device_directory / file_id[:2] / file_id
|
||||
if source.is_symlink() or not source.is_file():
|
||||
raise TargetedProfileBackupError("LixelGO preferences payload is missing")
|
||||
size = source.stat().st_size
|
||||
if not 1 <= size <= MAX_EXPORTED_PREFERENCES_BYTES:
|
||||
raise TargetedProfileBackupError(
|
||||
"LixelGO preferences payload size is outside the safe bound"
|
||||
)
|
||||
payload = source.read_bytes()
|
||||
parsed = plistlib.loads(payload)
|
||||
if not isinstance(parsed, dict):
|
||||
raise TargetedProfileBackupError("LixelGO preferences root is not a dictionary")
|
||||
return payload
|
||||
|
||||
|
||||
async def _filtered_backup(backup_root: Path) -> None:
|
||||
from pymobiledevice3.lockdown import create_using_usbmux
|
||||
from pymobiledevice3.services.device_link import DeviceLink
|
||||
from pymobiledevice3.services.mobilebackup2 import Mobilebackup2Service
|
||||
|
||||
original_free_disk_handler = DeviceLink.get_free_disk_space
|
||||
|
||||
async def report_filtered_capacity(self: Any, message: Any) -> None:
|
||||
del message
|
||||
actual = shutil.disk_usage(self.root_path).free
|
||||
await self.status_response(
|
||||
0,
|
||||
status_dict=max(actual, SYNTHETIC_AVAILABLE_BYTES),
|
||||
)
|
||||
|
||||
lockdown = await create_using_usbmux(connection_type="USB")
|
||||
try:
|
||||
with patch.object(
|
||||
DeviceLink,
|
||||
"get_free_disk_space",
|
||||
report_filtered_capacity,
|
||||
):
|
||||
async with Mobilebackup2Service(lockdown) as service:
|
||||
if await service.get_will_encrypt():
|
||||
raise TargetedProfileBackupError(
|
||||
"device backup encryption is enabled; no filtered export was attempted"
|
||||
)
|
||||
await service.backup(
|
||||
full=True,
|
||||
backup_directory=backup_root,
|
||||
filter_callback=_is_preferences_backup_file,
|
||||
)
|
||||
finally:
|
||||
DeviceLink.get_free_disk_space = original_free_disk_handler
|
||||
await lockdown.close()
|
||||
|
||||
|
||||
def _write_session(preferences: bytes) -> Path:
|
||||
session_id = (
|
||||
datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
+ f"_lixelgo-profile-export_{secrets.token_hex(2)}"
|
||||
)
|
||||
session_dir = SESSION_ROOT / session_id
|
||||
private_dir = session_dir / "private"
|
||||
private_dir.mkdir(parents=True, mode=0o700)
|
||||
preferences_path = private_dir / "lixelgo-preferences.plist"
|
||||
preferences_path.write_bytes(preferences)
|
||||
preferences_path.chmod(0o600)
|
||||
manifest = {
|
||||
"schema": "missioncore.xgrids-k1/lixelgo-profile-export/v1alpha1",
|
||||
"session_id": session_id,
|
||||
"status": "completed",
|
||||
"completed_at_utc": _utc_now(),
|
||||
"bundle_id": BUNDLE_ID,
|
||||
"source": "owner-authorized-usb-mobilebackup2-filtered",
|
||||
"preferences_present": True,
|
||||
"preferences_size_bytes": len(preferences),
|
||||
"device_identity_recorded": False,
|
||||
"preference_values_recorded_in_manifest": False,
|
||||
"credential_values_recorded_in_manifest": False,
|
||||
"temporary_backup_retained": False,
|
||||
}
|
||||
manifest_path = session_dir / "manifest.redacted.json"
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
manifest_path.chmod(0o600)
|
||||
return session_dir
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Export only LixelGO preferences from one trusted USB iPhone"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--acknowledge-filtered-capacity",
|
||||
action="store_true",
|
||||
help=(
|
||||
"confirm that the operator approved synthetic free-space reporting while "
|
||||
"all non-LixelGO preference payloads are discarded"
|
||||
),
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from preflight import pymobiledevice3_binary, usb_device_count
|
||||
|
||||
args = parse_args()
|
||||
if not args.acknowledge_filtered_capacity:
|
||||
raise SystemExit("explicit --acknowledge-filtered-capacity is required")
|
||||
os.umask(0o077)
|
||||
if usb_device_count(pymobiledevice3_binary()) != 1:
|
||||
raise SystemExit("exactly one trusted USB iPhone must be connected")
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="mission-core-lixelgo-") as temporary:
|
||||
backup_root = Path(temporary) / "backup"
|
||||
backup_root.mkdir(mode=0o700)
|
||||
asyncio.run(_filtered_backup(backup_root))
|
||||
preferences = _read_preferences(_single_device_directory(backup_root))
|
||||
session_dir = _write_session(preferences)
|
||||
except (
|
||||
OSError,
|
||||
sqlite3.Error,
|
||||
plistlib.InvalidFileException,
|
||||
TargetedProfileBackupError,
|
||||
) as exc:
|
||||
raise SystemExit(f"filtered LixelGO profile export failed: {exc}") from exc
|
||||
|
||||
print(f"session: {session_dir}")
|
||||
print("status: completed")
|
||||
print("credential values printed: no")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,79 +1,649 @@
|
||||
import AppKit
|
||||
import CoreWLAN
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
private struct AssociationRequest: Decodable {
|
||||
let ssid: String
|
||||
let password: String
|
||||
private let keychainService = "NODEDC Mission Core Host Wi-Fi Profiles"
|
||||
private let credentialMaterialKeychainService =
|
||||
"NODEDC Mission Core Device Credential Materials"
|
||||
|
||||
private struct HostWifiRequest: Decodable {
|
||||
let action: String
|
||||
let profileID: String
|
||||
let ssid: String?
|
||||
let password: String?
|
||||
let scanTimeoutSeconds: Double?
|
||||
let credentialSourceID: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case action
|
||||
case profileID = "profile_id"
|
||||
case ssid
|
||||
case password
|
||||
case scanTimeoutSeconds = "scan_timeout_seconds"
|
||||
case credentialSourceID = "credential_source_id"
|
||||
}
|
||||
}
|
||||
|
||||
private struct AssociationResponse: Encodable {
|
||||
private struct StoredProfile: Codable {
|
||||
let schemaVersion: Int
|
||||
let ssid: String
|
||||
let password: String
|
||||
let credentialSource: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case schemaVersion = "schema_version"
|
||||
case ssid
|
||||
case password
|
||||
case credentialSource = "credential_source"
|
||||
}
|
||||
}
|
||||
|
||||
private struct StoredCredentialMaterial: Codable {
|
||||
let schemaVersion: Int
|
||||
let password: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case schemaVersion = "schema_version"
|
||||
case password
|
||||
}
|
||||
}
|
||||
|
||||
private struct HostWifiResponse: Encodable {
|
||||
let ok: Bool
|
||||
let adapter: String?
|
||||
let alreadyAssociated: Bool?
|
||||
let stored: Bool?
|
||||
let found: Bool?
|
||||
let profileAvailable: Bool?
|
||||
let profileEnrolled: Bool?
|
||||
let scanAttemptCount: Int?
|
||||
let scanElapsedMilliseconds: Int?
|
||||
let credentialSource: String?
|
||||
let reasonCode: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case adapter
|
||||
case alreadyAssociated = "already_associated"
|
||||
case stored
|
||||
case found
|
||||
case profileAvailable = "profile_available"
|
||||
case profileEnrolled = "profile_enrolled"
|
||||
case scanAttemptCount = "scan_attempt_count"
|
||||
case scanElapsedMilliseconds = "scan_elapsed_ms"
|
||||
case credentialSource = "credential_source"
|
||||
case reasonCode = "reason_code"
|
||||
}
|
||||
}
|
||||
|
||||
private func emit(_ response: AssociationResponse, exitCode: Int32) -> Never {
|
||||
let encoder = JSONEncoder()
|
||||
if let data = try? encoder.encode(response) {
|
||||
private func emit(
|
||||
ok: Bool,
|
||||
adapter: String? = nil,
|
||||
alreadyAssociated: Bool? = nil,
|
||||
stored: Bool? = nil,
|
||||
found: Bool? = nil,
|
||||
profileAvailable: Bool? = nil,
|
||||
profileEnrolled: Bool? = nil,
|
||||
scanAttemptCount: Int? = nil,
|
||||
scanElapsedMilliseconds: Int? = nil,
|
||||
credentialSource: String? = nil,
|
||||
reasonCode: String? = nil,
|
||||
exitCode: Int32
|
||||
) -> Never {
|
||||
let response = HostWifiResponse(
|
||||
ok: ok,
|
||||
adapter: adapter,
|
||||
alreadyAssociated: alreadyAssociated,
|
||||
stored: stored,
|
||||
found: found,
|
||||
profileAvailable: profileAvailable,
|
||||
profileEnrolled: profileEnrolled,
|
||||
scanAttemptCount: scanAttemptCount,
|
||||
scanElapsedMilliseconds: scanElapsedMilliseconds,
|
||||
credentialSource: credentialSource,
|
||||
reasonCode: reasonCode
|
||||
)
|
||||
if let data = try? JSONEncoder().encode(response) {
|
||||
FileHandle.standardOutput.write(data)
|
||||
}
|
||||
exit(exitCode)
|
||||
}
|
||||
|
||||
private let input = FileHandle.standardInput.readDataToEndOfFile()
|
||||
guard input.count <= 1024 else {
|
||||
emit(
|
||||
AssociationResponse(ok: false, alreadyAssociated: nil, reasonCode: "request-too-large"),
|
||||
exitCode: 1
|
||||
private func profileIsValid(_ profile: StoredProfile) -> Bool {
|
||||
guard profile.schemaVersion == 1,
|
||||
let ssidData = profile.ssid.data(using: .utf8),
|
||||
(1 ... 32).contains(ssidData.count),
|
||||
let passwordData = profile.password.data(using: .utf8),
|
||||
(1 ... 64).contains(passwordData.count)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func credentialMaterialIsValid(_ material: StoredCredentialMaterial) -> Bool {
|
||||
guard material.schemaVersion == 1,
|
||||
let passwordData = material.password.data(using: .utf8),
|
||||
(8 ... 63).contains(passwordData.count)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func keychainQuery(service: String, account: String) -> [String: Any] {
|
||||
return [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
}
|
||||
|
||||
private func profileKeychainQuery(profileID: String) -> [String: Any] {
|
||||
return keychainQuery(service: keychainService, account: profileID)
|
||||
}
|
||||
|
||||
private func materialKeychainQuery(sourceID: String) -> [String: Any] {
|
||||
return keychainQuery(service: credentialMaterialKeychainService, account: sourceID)
|
||||
}
|
||||
|
||||
private func loadProfile(profileID: String) throws -> StoredProfile {
|
||||
var query = profileKeychainQuery(profileID: profileID)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard status == errSecSuccess, let data = item as? Data else {
|
||||
throw NSError(domain: "HostWifiProfile", code: Int(status))
|
||||
}
|
||||
let profile = try JSONDecoder().decode(StoredProfile.self, from: data)
|
||||
guard profileIsValid(profile) else {
|
||||
throw NSError(domain: "HostWifiProfile", code: Int(errSecDecode))
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
private func storeProfile(profileID: String, profile: StoredProfile) throws {
|
||||
guard profileIsValid(profile) else {
|
||||
throw NSError(domain: "HostWifiProfile", code: Int(errSecParam))
|
||||
}
|
||||
let data = try JSONEncoder().encode(profile)
|
||||
let query = profileKeychainQuery(profileID: profileID)
|
||||
let attributes = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
] as [String: Any]
|
||||
|
||||
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
if updateStatus == errSecSuccess {
|
||||
return
|
||||
}
|
||||
guard updateStatus == errSecItemNotFound else {
|
||||
throw NSError(domain: "HostWifiProfile", code: Int(updateStatus))
|
||||
}
|
||||
var newItem = query
|
||||
attributes.forEach { key, value in newItem[key] = value }
|
||||
let addStatus = SecItemAdd(newItem as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw NSError(domain: "HostWifiProfile", code: Int(addStatus))
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCredentialMaterial(sourceID: String) throws -> StoredCredentialMaterial {
|
||||
var query = materialKeychainQuery(sourceID: sourceID)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard status == errSecSuccess, let data = item as? Data else {
|
||||
throw NSError(domain: "HostWifiCredentialMaterial", code: Int(status))
|
||||
}
|
||||
let material = try JSONDecoder().decode(StoredCredentialMaterial.self, from: data)
|
||||
guard credentialMaterialIsValid(material) else {
|
||||
throw NSError(domain: "HostWifiCredentialMaterial", code: Int(errSecDecode))
|
||||
}
|
||||
return material
|
||||
}
|
||||
|
||||
private func storeCredentialMaterial(
|
||||
sourceID: String,
|
||||
material: StoredCredentialMaterial
|
||||
) throws {
|
||||
guard credentialMaterialIsValid(material) else {
|
||||
throw NSError(domain: "HostWifiCredentialMaterial", code: Int(errSecParam))
|
||||
}
|
||||
let data = try JSONEncoder().encode(material)
|
||||
let query = materialKeychainQuery(sourceID: sourceID)
|
||||
let attributes = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
] as [String: Any]
|
||||
|
||||
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
if updateStatus == errSecSuccess {
|
||||
return
|
||||
}
|
||||
guard updateStatus == errSecItemNotFound else {
|
||||
throw NSError(domain: "HostWifiCredentialMaterial", code: Int(updateStatus))
|
||||
}
|
||||
var newItem = query
|
||||
attributes.forEach { key, value in newItem[key] = value }
|
||||
let addStatus = SecItemAdd(newItem as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw NSError(domain: "HostWifiCredentialMaterial", code: Int(addStatus))
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSystemWiFiProfile(ssid: String, ssidData: Data) -> StoredProfile? {
|
||||
var password: NSString?
|
||||
let status = CWKeychainFindWiFiPassword(
|
||||
CWKeychainDomain.user,
|
||||
ssidData,
|
||||
&password
|
||||
)
|
||||
guard status == errSecSuccess, let password else {
|
||||
return nil
|
||||
}
|
||||
let profile = StoredProfile(
|
||||
schemaVersion: 1,
|
||||
ssid: ssid,
|
||||
password: password as String,
|
||||
credentialSource: "system-wifi-keychain"
|
||||
)
|
||||
return profileIsValid(profile) ? profile : nil
|
||||
}
|
||||
|
||||
private struct TargetedScanResult {
|
||||
let network: CWNetwork?
|
||||
let attemptCount: Int
|
||||
let elapsedMilliseconds: Int
|
||||
}
|
||||
|
||||
private func scanForExpectedNetwork(
|
||||
interface: CWInterface,
|
||||
ssid: String,
|
||||
ssidData: Data,
|
||||
timeoutSeconds: Double
|
||||
) throws -> TargetedScanResult {
|
||||
let started = ProcessInfo.processInfo.systemUptime
|
||||
var attemptCount = 0
|
||||
|
||||
while true {
|
||||
attemptCount += 1
|
||||
let networks = try interface.scanForNetworks(withSSID: ssidData)
|
||||
if let network = networks.first(where: { $0.ssid == ssid }) {
|
||||
return TargetedScanResult(
|
||||
network: network,
|
||||
attemptCount: attemptCount,
|
||||
elapsedMilliseconds: max(
|
||||
0,
|
||||
Int((ProcessInfo.processInfo.systemUptime - started) * 1_000)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let elapsed = ProcessInfo.processInfo.systemUptime - started
|
||||
let remaining = timeoutSeconds - elapsed
|
||||
if remaining <= 0 {
|
||||
return TargetedScanResult(
|
||||
network: nil,
|
||||
attemptCount: attemptCount,
|
||||
elapsedMilliseconds: max(0, Int(elapsed * 1_000))
|
||||
)
|
||||
}
|
||||
Thread.sleep(forTimeInterval: min(0.75, remaining))
|
||||
}
|
||||
}
|
||||
|
||||
private func promptForDevicePassword(ssid: String) -> String? {
|
||||
let application = NSApplication.shared
|
||||
application.setActivationPolicy(.accessory)
|
||||
|
||||
let passwordField = NSSecureTextField(frame: NSRect(x: 0, y: 0, width: 360, height: 24))
|
||||
passwordField.placeholderString = "Пароль точки доступа K1"
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .informational
|
||||
alert.messageText = "Первое подключение к \(ssid)"
|
||||
alert.informativeText = "macOS не нашла локальный профиль этой точки доступа. Если credential вам неизвестен, нажмите «Отмена» и выполните авторизованный импорт device-профиля LixelGO. Введённое значение будет сохранено только в Keychain этого Mac и не попадёт в браузер, API, журнал или evidence Mission Core."
|
||||
alert.accessoryView = passwordField
|
||||
alert.addButton(withTitle: "Подключиться")
|
||||
alert.addButton(withTitle: "Отмена")
|
||||
|
||||
application.activate(ignoringOtherApps: true)
|
||||
guard alert.runModal() == .alertFirstButtonReturn else {
|
||||
return nil
|
||||
}
|
||||
return passwordField.stringValue
|
||||
}
|
||||
|
||||
private let input = FileHandle.standardInput.readDataToEndOfFile()
|
||||
guard input.count <= 2048 else {
|
||||
emit(ok: false, reasonCode: "request-too-large", exitCode: 1)
|
||||
}
|
||||
|
||||
do {
|
||||
let request = try JSONDecoder().decode(AssociationRequest.self, from: input)
|
||||
guard let ssidData = request.ssid.data(using: .utf8),
|
||||
(1 ... 32).contains(ssidData.count),
|
||||
let passwordData = request.password.data(using: .utf8),
|
||||
(1 ... 64).contains(passwordData.count)
|
||||
let request = try JSONDecoder().decode(HostWifiRequest.self, from: input)
|
||||
guard (1 ... 128).contains(request.profileID.count),
|
||||
request.profileID.allSatisfy({
|
||||
$0.isASCII && ($0.isLetter || $0.isNumber || ".-_".contains($0))
|
||||
})
|
||||
else {
|
||||
emit(ok: false, reasonCode: "profile-id-invalid", exitCode: 1)
|
||||
}
|
||||
|
||||
if request.action == "store-profile" {
|
||||
guard let ssid = request.ssid, let password = request.password else {
|
||||
emit(ok: false, reasonCode: "credential-missing", exitCode: 1)
|
||||
}
|
||||
try storeProfile(
|
||||
profileID: request.profileID,
|
||||
profile: StoredProfile(
|
||||
schemaVersion: 1,
|
||||
ssid: ssid,
|
||||
password: password,
|
||||
credentialSource: nil
|
||||
)
|
||||
)
|
||||
emit(ok: true, adapter: "macOS Keychain", stored: true, exitCode: 0)
|
||||
}
|
||||
|
||||
if request.action == "store-credential-material" {
|
||||
guard let password = request.password else {
|
||||
emit(ok: false, reasonCode: "credential-missing", exitCode: 1)
|
||||
}
|
||||
try storeCredentialMaterial(
|
||||
sourceID: request.profileID,
|
||||
material: StoredCredentialMaterial(schemaVersion: 1, password: password)
|
||||
)
|
||||
emit(ok: true, adapter: "macOS Keychain", stored: true, exitCode: 0)
|
||||
}
|
||||
|
||||
if request.action == "check-credential-material" {
|
||||
do {
|
||||
_ = try loadCredentialMaterial(sourceID: request.profileID)
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: true,
|
||||
credentialSource: "exact-firmware-profile",
|
||||
exitCode: 0
|
||||
)
|
||||
} catch {
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: false,
|
||||
exitCode: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if request.action == "ensure-profile" {
|
||||
guard let ssid = request.ssid,
|
||||
let ssidData = ssid.data(using: .utf8),
|
||||
(1 ... 32).contains(ssidData.count),
|
||||
let sourceID = request.credentialSourceID,
|
||||
(1 ... 128).contains(sourceID.count),
|
||||
sourceID.allSatisfy({
|
||||
$0.isASCII && ($0.isLetter || $0.isNumber || ".-_".contains($0))
|
||||
})
|
||||
else {
|
||||
emit(ok: false, reasonCode: "credential-source-invalid", exitCode: 1)
|
||||
}
|
||||
|
||||
let material: StoredCredentialMaterial
|
||||
do {
|
||||
material = try loadCredentialMaterial(sourceID: sourceID)
|
||||
} catch {
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: false,
|
||||
profileEnrolled: false,
|
||||
reasonCode: "credential-source-unavailable",
|
||||
exitCode: 0
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
let existing = try loadProfile(profileID: request.profileID)
|
||||
guard existing.ssid == ssid else {
|
||||
emit(
|
||||
ok: false,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: false,
|
||||
profileEnrolled: false,
|
||||
reasonCode: "profile-ssid-mismatch",
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
if existing.password == material.password,
|
||||
existing.credentialSource != "exact-firmware-profile" {
|
||||
try storeProfile(
|
||||
profileID: request.profileID,
|
||||
profile: StoredProfile(
|
||||
schemaVersion: 1,
|
||||
ssid: ssid,
|
||||
password: existing.password,
|
||||
credentialSource: "exact-firmware-profile"
|
||||
)
|
||||
)
|
||||
}
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: true,
|
||||
profileEnrolled: false,
|
||||
credentialSource: existing.password == material.password
|
||||
? "exact-firmware-profile"
|
||||
: (existing.credentialSource ?? "mission-core-keychain"),
|
||||
exitCode: 0
|
||||
)
|
||||
} catch {
|
||||
try storeProfile(
|
||||
profileID: request.profileID,
|
||||
profile: StoredProfile(
|
||||
schemaVersion: 1,
|
||||
ssid: ssid,
|
||||
password: material.password,
|
||||
credentialSource: "exact-firmware-profile"
|
||||
)
|
||||
)
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: true,
|
||||
profileEnrolled: true,
|
||||
credentialSource: "exact-firmware-profile",
|
||||
exitCode: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if request.action == "check-profile" {
|
||||
let profile: StoredProfile
|
||||
do {
|
||||
profile = try loadProfile(profileID: request.profileID)
|
||||
} catch {
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: false,
|
||||
exitCode: 0
|
||||
)
|
||||
}
|
||||
if let expectedSSID = request.ssid, profile.ssid != expectedSSID {
|
||||
emit(
|
||||
ok: false,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: false,
|
||||
reasonCode: "profile-ssid-mismatch",
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
emit(
|
||||
AssociationResponse(ok: false, alreadyAssociated: nil, reasonCode: "credential-bounds"),
|
||||
exitCode: 1
|
||||
ok: true,
|
||||
adapter: "macOS Keychain",
|
||||
profileAvailable: true,
|
||||
exitCode: 0
|
||||
)
|
||||
}
|
||||
|
||||
guard request.action == "associate" || request.action == "scan-profile" else {
|
||||
emit(ok: false, reasonCode: "action-unsupported", exitCode: 1)
|
||||
}
|
||||
guard let interface = CWWiFiClient.shared().interface() else {
|
||||
emit(ok: false, reasonCode: "wifi-interface-unavailable", exitCode: 1)
|
||||
}
|
||||
let scanTimeoutSeconds = request.scanTimeoutSeconds ?? 0
|
||||
guard scanTimeoutSeconds >= 0, scanTimeoutSeconds <= 60 else {
|
||||
emit(ok: false, reasonCode: "scan-timeout-invalid", exitCode: 1)
|
||||
}
|
||||
|
||||
if request.action == "scan-profile" {
|
||||
let profile: StoredProfile
|
||||
do {
|
||||
profile = try loadProfile(profileID: request.profileID)
|
||||
} catch {
|
||||
emit(ok: false, reasonCode: "profile-unavailable", exitCode: 1)
|
||||
}
|
||||
let ssidData = profile.ssid.data(using: .utf8)!
|
||||
let scan = try scanForExpectedNetwork(
|
||||
interface: interface,
|
||||
ssid: profile.ssid,
|
||||
ssidData: ssidData,
|
||||
timeoutSeconds: scanTimeoutSeconds
|
||||
)
|
||||
emit(
|
||||
AssociationResponse(ok: false, alreadyAssociated: nil, reasonCode: "wifi-interface-unavailable"),
|
||||
ok: true,
|
||||
adapter: "CoreWLAN",
|
||||
found: scan.network != nil,
|
||||
scanAttemptCount: scan.attemptCount,
|
||||
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
||||
exitCode: 0
|
||||
)
|
||||
}
|
||||
|
||||
guard request.action == "associate" else {
|
||||
emit(ok: false, reasonCode: "action-unsupported", exitCode: 1)
|
||||
}
|
||||
guard let expectedSSID = request.ssid,
|
||||
let expectedSSIDData = expectedSSID.data(using: .utf8),
|
||||
(1 ... 32).contains(expectedSSIDData.count)
|
||||
else {
|
||||
emit(ok: false, reasonCode: "ssid-invalid", exitCode: 1)
|
||||
}
|
||||
|
||||
let scan = try scanForExpectedNetwork(
|
||||
interface: interface,
|
||||
ssid: expectedSSID,
|
||||
ssidData: expectedSSIDData,
|
||||
timeoutSeconds: scanTimeoutSeconds
|
||||
)
|
||||
guard let network = scan.network else {
|
||||
emit(
|
||||
ok: false,
|
||||
scanAttemptCount: scan.attemptCount,
|
||||
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
||||
reasonCode: "network-not-found",
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
|
||||
if interface.ssid() == request.ssid {
|
||||
emit(AssociationResponse(ok: true, alreadyAssociated: true, reasonCode: nil), exitCode: 0)
|
||||
var profileEnrolled = false
|
||||
var profileNeedsStore = false
|
||||
let credentialSource: String
|
||||
let profile: StoredProfile
|
||||
do {
|
||||
profile = try loadProfile(profileID: request.profileID)
|
||||
credentialSource = profile.credentialSource ?? "mission-core-keychain"
|
||||
} catch {
|
||||
if let systemProfile = loadSystemWiFiProfile(
|
||||
ssid: expectedSSID,
|
||||
ssidData: expectedSSIDData
|
||||
) {
|
||||
profile = systemProfile
|
||||
credentialSource = "system-wifi-keychain"
|
||||
profileNeedsStore = true
|
||||
} else {
|
||||
guard let password = promptForDevicePassword(ssid: expectedSSID) else {
|
||||
emit(
|
||||
ok: false,
|
||||
scanAttemptCount: scan.attemptCount,
|
||||
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
||||
reasonCode: "credential-entry-cancelled",
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
guard let passwordData = password.data(using: .utf8),
|
||||
(1 ... 64).contains(passwordData.count)
|
||||
else {
|
||||
emit(
|
||||
ok: false,
|
||||
scanAttemptCount: scan.attemptCount,
|
||||
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
||||
reasonCode: "credential-invalid",
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
profile = StoredProfile(
|
||||
schemaVersion: 1,
|
||||
ssid: expectedSSID,
|
||||
password: password,
|
||||
credentialSource: "native-secure-prompt"
|
||||
)
|
||||
credentialSource = "native-secure-prompt"
|
||||
profileNeedsStore = true
|
||||
}
|
||||
}
|
||||
|
||||
let networks = try interface.scanForNetworks(withSSID: ssidData)
|
||||
guard let network = networks.first(where: { $0.ssid == request.ssid }) else {
|
||||
guard profile.ssid == expectedSSID else {
|
||||
emit(
|
||||
AssociationResponse(ok: false, alreadyAssociated: nil, reasonCode: "network-not-found"),
|
||||
ok: false,
|
||||
scanAttemptCount: scan.attemptCount,
|
||||
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
||||
reasonCode: "profile-ssid-mismatch",
|
||||
exitCode: 1
|
||||
)
|
||||
}
|
||||
|
||||
try interface.associate(to: network, password: request.password)
|
||||
if interface.ssid() == profile.ssid {
|
||||
if profileNeedsStore {
|
||||
try storeProfile(profileID: request.profileID, profile: profile)
|
||||
profileEnrolled = true
|
||||
}
|
||||
emit(
|
||||
ok: true,
|
||||
adapter: "CoreWLAN",
|
||||
alreadyAssociated: true,
|
||||
profileEnrolled: profileEnrolled,
|
||||
scanAttemptCount: scan.attemptCount,
|
||||
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
||||
credentialSource: credentialSource,
|
||||
exitCode: 0
|
||||
)
|
||||
}
|
||||
try interface.associate(to: network, password: profile.password)
|
||||
if profileNeedsStore {
|
||||
try storeProfile(profileID: request.profileID, profile: profile)
|
||||
profileEnrolled = true
|
||||
}
|
||||
// CoreWLAN's synchronous association call throws on failure. Reading the
|
||||
// current SSID again would require Location authorization on recent macOS
|
||||
// versions and could turn a successful association into a false negative.
|
||||
emit(AssociationResponse(ok: true, alreadyAssociated: false, reasonCode: nil), exitCode: 0)
|
||||
} catch {
|
||||
emit(
|
||||
AssociationResponse(ok: false, alreadyAssociated: nil, reasonCode: "corewlan-error"),
|
||||
exitCode: 1
|
||||
ok: true,
|
||||
adapter: "CoreWLAN",
|
||||
alreadyAssociated: false,
|
||||
profileEnrolled: profileEnrolled,
|
||||
scanAttemptCount: scan.attemptCount,
|
||||
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
||||
credentialSource: credentialSource,
|
||||
exitCode: 0
|
||||
)
|
||||
} catch {
|
||||
emit(ok: false, reasonCode: "corewlan-error", exitCode: 1)
|
||||
}
|
||||
|
||||
@@ -156,12 +156,12 @@ def _validate_connection_modes(scope: dict[str, Any]) -> None:
|
||||
"quick-connect": {
|
||||
"topology": "device-ap",
|
||||
"direction": "controller-joins-k1-network",
|
||||
"device_network_action": "none",
|
||||
"host_network_action": "single-corewlan-association",
|
||||
"target_address_policy": "reviewed-fixed-k1-ap-private-ipv4",
|
||||
"acceptance": (
|
||||
"lixelgo-data-plane-observed-mission-core-physical-acceptance-pending"
|
||||
"device_network_action": "single-reviewed-ble-ap-activation-write",
|
||||
"host_network_action": (
|
||||
"exact-firmware-provider-and-single-corewlan-association"
|
||||
),
|
||||
"target_address_policy": "reviewed-fixed-k1-ap-private-ipv4",
|
||||
"acceptance": "prepared-macos-host-physical-accepted",
|
||||
},
|
||||
}
|
||||
if set(modes) != set(expected):
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"version": "3.0.2"
|
||||
},
|
||||
"topology": "local-network-matrix",
|
||||
"claim_limit": "One owner-controlled K1 on exact firmware 3.0.2 across controlled Bridge/Mission Core and owner-operated Quick Connect LixelGO/iPhone runs. Direct Connect reuses the physically accepted 99-byte BLE Wi-Fi frame but still requires its own Mission Core physical acceptance. This is not a vendor API or a cross-firmware compatibility claim.",
|
||||
"claim_limit": "One owner-controlled K1 on exact firmware 3.0.2. Bridge/Mission Core is physically accepted. Quick Connect AP activation and association are accepted only on one prepared macOS host with a preinstalled firmware provider; clean-host bootstrap is not implemented. Direct Connect reuses the physically accepted 99-byte BLE Wi-Fi frame but still requires its own Mission Core physical acceptance. This is not a vendor API or a cross-firmware compatibility claim.",
|
||||
"connection_modes": [
|
||||
{
|
||||
"id": "bridge",
|
||||
@@ -35,10 +35,10 @@
|
||||
"id": "quick-connect",
|
||||
"topology": "device-ap",
|
||||
"direction": "controller-joins-k1-network",
|
||||
"device_network_action": "none",
|
||||
"host_network_action": "single-corewlan-association",
|
||||
"device_network_action": "single-reviewed-ble-ap-activation-write",
|
||||
"host_network_action": "exact-firmware-provider-and-single-corewlan-association",
|
||||
"target_address_policy": "reviewed-fixed-k1-ap-private-ipv4",
|
||||
"acceptance": "lixelgo-data-plane-observed-mission-core-physical-acceptance-pending"
|
||||
"acceptance": "prepared-macos-host-physical-accepted"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -91,6 +91,12 @@
|
||||
"kind": "redacted-physical-protocol-report",
|
||||
"path": "docs/lab/002_LIXELGO_IPHONE_LOCAL_PROTOCOL_20260716.redacted.md",
|
||||
"scope": "Owner-operated LixelGO start/stop mapping, local-only bounded traffic result and left/right RTSP/H.264 camera-preview discovery."
|
||||
},
|
||||
{
|
||||
"id": "lab-004",
|
||||
"kind": "redacted-physical-and-firmware-analysis-report",
|
||||
"path": "docs/lab/004_K1_FW302_AP_CREDENTIAL_PROVIDER_20260720.redacted.md",
|
||||
"scope": "Official K1 3.0.2 firmware credential source, SSID construction, secure provider import, prepared-host physical association and the decision to keep Quick Connect laboratory-only."
|
||||
}
|
||||
],
|
||||
"transports": [
|
||||
@@ -118,7 +124,8 @@
|
||||
},
|
||||
"source_ids": [
|
||||
"wifi-provisioning-profile",
|
||||
"lab-001"
|
||||
"lab-001",
|
||||
"lab-004"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user