wip(k1): checkpoint connection recovery rewrite
Capture the current unreleased K1 connection, recovery, lifecycle, viewer, and test work as a single known-bad baseline for subsequent fixes.
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
"""Host-owned network adapters used by device plugins."""
|
||||
|
||||
from k1link.host_network.wifi import (
|
||||
HostWifiAssociationIdentityProbe,
|
||||
HostWifiAssociationIdentityResult,
|
||||
HostWifiCredentialMaterialAvailabilityResult,
|
||||
HostWifiCredentialMaterialStoreResult,
|
||||
HostWifiProfileError,
|
||||
associate_with_ephemeral_wifi_credentials_once,
|
||||
associate_with_wifi_profile_once,
|
||||
bind_route_fingerprint_to_wifi_association,
|
||||
check_wifi_credential_material,
|
||||
check_wifi_profile,
|
||||
ensure_wifi_profile_from_credential_source,
|
||||
@@ -12,10 +16,14 @@ from k1link.host_network.wifi import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"HostWifiAssociationIdentityProbe",
|
||||
"HostWifiAssociationIdentityResult",
|
||||
"HostWifiCredentialMaterialAvailabilityResult",
|
||||
"HostWifiCredentialMaterialStoreResult",
|
||||
"HostWifiProfileError",
|
||||
"associate_with_ephemeral_wifi_credentials_once",
|
||||
"associate_with_wifi_profile_once",
|
||||
"bind_route_fingerprint_to_wifi_association",
|
||||
"check_wifi_credential_material",
|
||||
"check_wifi_profile",
|
||||
"ensure_wifi_profile_from_credential_source",
|
||||
|
||||
+564
-236
@@ -1,15 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import secrets
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager, suppress
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
@@ -35,6 +37,7 @@ class HostWifiProfileAvailabilityResult(TypedDict):
|
||||
schema_version: int
|
||||
adapter: str
|
||||
available: bool
|
||||
credential_source: str | None
|
||||
|
||||
|
||||
class HostWifiProfileEnsureResult(TypedDict):
|
||||
@@ -57,6 +60,24 @@ class HostWifiCredentialMaterialAvailabilityResult(TypedDict):
|
||||
available: bool
|
||||
|
||||
|
||||
class HostWifiAssociationIdentityResult(TypedDict):
|
||||
"""Secret-free continuity evidence for one host network interface.
|
||||
|
||||
``continuity_token`` is scoped to one probe instance and is safe to fold
|
||||
into another local route fingerprint. It is not an SSID/BSSID digest that
|
||||
can be compared between Mission Core processes.
|
||||
"""
|
||||
|
||||
schema_version: int
|
||||
adapter: str
|
||||
wifi_interface: bool | None
|
||||
association_state: Literal["associated", "not-wifi", "unavailable"]
|
||||
evidence_quality: Literal["ssid+bssid", "bssid-only", "not-wifi", "unavailable"]
|
||||
continuity_proven: bool
|
||||
continuity_token: str
|
||||
reason_code: str | None
|
||||
|
||||
|
||||
_ERROR_MESSAGES = {
|
||||
"unsupported-platform": (
|
||||
"для этой ОС ещё не установлен адаптер системных Wi-Fi-профилей"
|
||||
@@ -68,22 +89,28 @@ _ERROR_MESSAGES = {
|
||||
"credential provider точной версии прошивки не установлен на этом устройстве"
|
||||
),
|
||||
"network-not-found": (
|
||||
"точка доступа выбранного K1 не найдена; проверьте режим K1 и питание"
|
||||
"K1 подтвердил режим точки доступа, но macOS не увидела её Wi-Fi-сеть "
|
||||
"за отведённое время"
|
||||
),
|
||||
"profile-ssid-mismatch": "сохранённый профиль принадлежит другой точке доступа",
|
||||
"credential-entry-cancelled": "ввод пароля точки доступа K1 отменён оператором",
|
||||
"credential-invalid": "пароль точки доступа K1 имеет недопустимую длину",
|
||||
"profile-credential-source-mismatch": (
|
||||
"сохранённый профиль K1 не связан с точной версией firmware provider"
|
||||
),
|
||||
"host-wifi-operation-timeout": (
|
||||
"оператор не завершил системное подключение Wi-Fi за отведённое время"
|
||||
),
|
||||
"host-wifi-helper-build-timeout": (
|
||||
"локальный Wi-Fi helper не успел скомпилироваться за отведённое время"
|
||||
),
|
||||
"host-wifi-helper-build-failed": "локальный Wi-Fi helper не удалось скомпилировать",
|
||||
"host-wifi-helper-compiler-unavailable": "компилятор локального Wi-Fi helper недоступен",
|
||||
"host-wifi-helper-cache-unavailable": "кэш локального Wi-Fi helper недоступен",
|
||||
"host-wifi-helper-missing": "исходный файл локального Wi-Fi helper не найден",
|
||||
"host-wifi-helper-unavailable": "локальный Wi-Fi helper недоступен",
|
||||
"keychain-authorization-required": (
|
||||
"локальный профиль K1 требует отдельного разрешения связки ключей"
|
||||
),
|
||||
"keychain-authorization-denied": "доступ к локальному профилю K1 запрещён",
|
||||
"keychain-authorization-cancelled": "разрешение связки ключей отменено оператором",
|
||||
"keychain-access-failed": "локальный профиль K1 недоступен в связке ключей",
|
||||
"corewlan-authorization-denied": (
|
||||
"macOS не разрешила локальному сервису выполнять системную Wi-Fi-операцию"
|
||||
),
|
||||
"wifi-interface-inactive": "системный Wi-Fi-интерфейс выключен или неактивен",
|
||||
"wifi-interface-unavailable": "системный Wi-Fi-интерфейс недоступен",
|
||||
"corewlan-error": "системный Wi-Fi не смог подключиться к точке доступа K1",
|
||||
}
|
||||
@@ -111,7 +138,11 @@ class HostWifiProfileError(RuntimeError):
|
||||
|
||||
|
||||
RunProcess = Callable[..., subprocess.CompletedProcess[bytes]]
|
||||
DEFAULT_HELPER_BUILD_TIMEOUT_SECONDS = 120.0
|
||||
REVIEWED_MACOS_HELPER_COMMAND_PREFIX = ("/usr/bin/xcrun", "swift")
|
||||
_ASSOCIATION_INSPECTION_PROFILE_ID = "host-association-inspection.v1"
|
||||
_EPHEMERAL_ASSOCIATION_PROFILE_ID = "bridge-operation-memory.v1"
|
||||
_ASSOCIATION_IDENTITY_HEX_LENGTH = 64
|
||||
MAX_ASSOCIATION_OBSERVATION_CACHE_AGE_SECONDS = 0.75
|
||||
|
||||
|
||||
def _validate_profile_id(profile_id: str) -> None:
|
||||
@@ -124,232 +155,45 @@ def _validate_profile_id(profile_id: str) -> None:
|
||||
raise ValueError("host Wi-Fi profile id contains unsupported characters")
|
||||
|
||||
|
||||
def _helper_cache_directory(helper_path: Path) -> Path:
|
||||
"""Resolve the process-local helper cache without an environment override."""
|
||||
|
||||
source = helper_path.expanduser().resolve()
|
||||
for ancestor in source.parents:
|
||||
if ancestor.name != "plugins":
|
||||
continue
|
||||
try:
|
||||
relative = source.relative_to(ancestor)
|
||||
except ValueError: # pragma: no cover - guarded by Path.parents
|
||||
continue
|
||||
if relative.parts[:2] == ("xgrids-k1", "macos"):
|
||||
return ancestor.parent / ".runtime" / "mission-core" / "helpers"
|
||||
# Tests and separately packaged adapters still get a stable cache beside
|
||||
# their source tree. The repository layout above is the production path.
|
||||
return source.parent / ".runtime" / "mission-core" / "helpers"
|
||||
|
||||
|
||||
def _compiled_macos_helper_path(helper_path: Path) -> Path:
|
||||
source = helper_path.expanduser().resolve()
|
||||
source_sha256 = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
return _helper_cache_directory(source) / f"{source.stem}-{source_sha256}"
|
||||
|
||||
|
||||
def _is_ready_executable(path: Path) -> bool:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError:
|
||||
return False
|
||||
return (
|
||||
stat.S_ISREG(metadata.st_mode)
|
||||
and metadata.st_size > 0
|
||||
and metadata.st_mode & 0o111 != 0
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_helper_build_lock(
|
||||
path: Path,
|
||||
*,
|
||||
timeout_seconds: float,
|
||||
) -> Iterator[None]:
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError as exc: # pragma: no cover - production target is macOS
|
||||
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
|
||||
|
||||
flags = (
|
||||
os.O_RDWR
|
||||
| os.O_CREAT
|
||||
| getattr(os, "O_CLOEXEC", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
)
|
||||
try:
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
except OSError as exc:
|
||||
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
|
||||
locked = False
|
||||
try:
|
||||
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
||||
raise HostWifiProfileError("host-wifi-helper-cache-unavailable")
|
||||
lock_started = time.monotonic()
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except BlockingIOError as exc:
|
||||
elapsed_seconds = max(0.0, time.monotonic() - lock_started)
|
||||
if elapsed_seconds >= timeout_seconds:
|
||||
raise HostWifiProfileError(
|
||||
"host-wifi-helper-build-timeout",
|
||||
helper_stage="compile-lock",
|
||||
helper_elapsed_ms=int(elapsed_seconds * 1000),
|
||||
) from exc
|
||||
time.sleep(min(0.05, timeout_seconds - elapsed_seconds))
|
||||
except OSError as exc:
|
||||
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
|
||||
locked = True
|
||||
yield
|
||||
finally:
|
||||
if locked:
|
||||
with suppress(OSError):
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
try:
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _ensure_compiled_macos_helper(
|
||||
helper_path: Path,
|
||||
*,
|
||||
build_timeout_seconds: float,
|
||||
runner: RunProcess,
|
||||
) -> Path:
|
||||
"""Build the source-hash-addressed helper once and reuse it thereafter."""
|
||||
|
||||
if build_timeout_seconds <= 0:
|
||||
raise ValueError("build_timeout_seconds must be positive")
|
||||
source = helper_path.expanduser().resolve()
|
||||
if not source.is_file():
|
||||
raise HostWifiProfileError("host-wifi-helper-missing")
|
||||
try:
|
||||
executable = _compiled_macos_helper_path(source)
|
||||
executable.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
|
||||
if _is_ready_executable(executable):
|
||||
return executable
|
||||
|
||||
build_started_ns = time.monotonic_ns()
|
||||
build_deadline = time.monotonic() + build_timeout_seconds
|
||||
|
||||
def build_error(
|
||||
reason_code: str,
|
||||
*,
|
||||
helper_stage: str = "compile",
|
||||
) -> HostWifiProfileError:
|
||||
elapsed_ms = max(0, (time.monotonic_ns() - build_started_ns) // 1_000_000)
|
||||
return HostWifiProfileError(
|
||||
reason_code,
|
||||
helper_stage=helper_stage,
|
||||
helper_elapsed_ms=elapsed_ms,
|
||||
)
|
||||
|
||||
lock_path = executable.with_name(f".{executable.name}.lock")
|
||||
with _exclusive_helper_build_lock(
|
||||
lock_path,
|
||||
timeout_seconds=max(0.001, build_deadline - time.monotonic()),
|
||||
):
|
||||
if _is_ready_executable(executable):
|
||||
return executable
|
||||
|
||||
try:
|
||||
descriptor, staging_name = tempfile.mkstemp(
|
||||
prefix=f".{executable.name}.",
|
||||
suffix=".tmp",
|
||||
dir=executable.parent,
|
||||
)
|
||||
os.close(descriptor)
|
||||
staging = Path(staging_name)
|
||||
staging.unlink()
|
||||
except OSError as exc:
|
||||
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
|
||||
|
||||
try:
|
||||
remaining_build_seconds = build_deadline - time.monotonic()
|
||||
if remaining_build_seconds <= 0:
|
||||
raise build_error("host-wifi-helper-build-timeout")
|
||||
|
||||
try:
|
||||
completed = runner(
|
||||
[
|
||||
"/usr/bin/xcrun",
|
||||
"swiftc",
|
||||
str(source),
|
||||
"-o",
|
||||
str(staging),
|
||||
],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=remaining_build_seconds,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise build_error("host-wifi-helper-build-timeout") from exc
|
||||
except OSError as exc:
|
||||
raise build_error("host-wifi-helper-compiler-unavailable") from exc
|
||||
|
||||
if completed.returncode != 0 or not _is_ready_executable(staging):
|
||||
raise build_error("host-wifi-helper-build-failed")
|
||||
try:
|
||||
staging.chmod(0o700)
|
||||
with staging.open("rb") as stream:
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(staging, executable)
|
||||
_fsync_directory(executable.parent)
|
||||
except OSError as exc:
|
||||
raise HostWifiProfileError("host-wifi-helper-cache-unavailable") from exc
|
||||
finally:
|
||||
with suppress(OSError):
|
||||
staging.unlink(missing_ok=True)
|
||||
return executable
|
||||
|
||||
|
||||
def _run_macos_helper(
|
||||
helper_path: Path,
|
||||
request: dict[str, object],
|
||||
*,
|
||||
timeout_seconds: float,
|
||||
runner: RunProcess,
|
||||
build_timeout_seconds: float = DEFAULT_HELPER_BUILD_TIMEOUT_SECONDS,
|
||||
process_fence_descriptor_factory: Callable[[], int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if sys.platform != "darwin":
|
||||
raise HostWifiProfileError("unsupported-platform")
|
||||
source = helper_path.expanduser().resolve()
|
||||
if not source.is_file():
|
||||
raise HostWifiProfileError("host-wifi-helper-missing")
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
|
||||
executable = _ensure_compiled_macos_helper(
|
||||
helper_path,
|
||||
build_timeout_seconds=build_timeout_seconds,
|
||||
runner=runner,
|
||||
)
|
||||
|
||||
request_bytes = bytearray(
|
||||
json.dumps(request, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
)
|
||||
try:
|
||||
completed = runner(
|
||||
[str(executable)],
|
||||
input=request_bytes,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
command = [*REVIEWED_MACOS_HELPER_COMMAND_PREFIX, str(source)]
|
||||
completed = (
|
||||
_run_macos_helper_with_process_fence(
|
||||
command,
|
||||
request_bytes=request_bytes,
|
||||
timeout_seconds=timeout_seconds,
|
||||
process_fence_descriptor_factory=(
|
||||
process_fence_descriptor_factory
|
||||
),
|
||||
)
|
||||
if process_fence_descriptor_factory is not None
|
||||
else runner(
|
||||
command,
|
||||
input=request_bytes,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise HostWifiProfileError("host-wifi-operation-timeout") from exc
|
||||
@@ -389,22 +233,422 @@ def _run_macos_helper(
|
||||
return response
|
||||
|
||||
|
||||
def _run_macos_helper_with_process_fence(
|
||||
command: list[str],
|
||||
*,
|
||||
request_bytes: bytearray,
|
||||
timeout_seconds: float,
|
||||
process_fence_descriptor_factory: Callable[[], int],
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
"""Run one mutating helper in a fenced, killable POSIX process group."""
|
||||
|
||||
if os.name != "posix":
|
||||
raise HostWifiProfileError("host-wifi-helper-unavailable")
|
||||
inherited_descriptor = process_fence_descriptor_factory()
|
||||
process: subprocess.Popen[bytes] | None = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
pass_fds=(inherited_descriptor,),
|
||||
)
|
||||
finally:
|
||||
# Popen has either inherited the descriptor into the child or failed.
|
||||
# The parent-side duplicate must never extend ownership on its own.
|
||||
os.close(inherited_descriptor)
|
||||
try:
|
||||
stdout, stderr = process.communicate(
|
||||
# subprocess accepts any bytes-like object at runtime. Keep the
|
||||
# mutable buffer so the caller can zero credentials in-place.
|
||||
input=request_bytes, # type: ignore[arg-type]
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
except BaseException:
|
||||
_kill_and_reap_process_group(process)
|
||||
raise
|
||||
return subprocess.CompletedProcess(
|
||||
args=command,
|
||||
returncode=process.returncode,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
|
||||
|
||||
def _kill_and_reap_process_group(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Prove the helper and every descendant are gone before dropping a fence."""
|
||||
|
||||
with suppress(ProcessLookupError, PermissionError):
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
try:
|
||||
process.communicate(timeout=5.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
with suppress(OSError):
|
||||
process.kill()
|
||||
process.wait(timeout=5.0)
|
||||
|
||||
|
||||
class HostWifiAssociationIdentityProbe:
|
||||
"""Observe macOS Wi-Fi association continuity without exporting its name.
|
||||
|
||||
The Swift helper HMACs its in-process SSID/BSSID evidence with a random key
|
||||
supplied over stdin. This object retains that key only for its own process
|
||||
lifetime, so equal networks cannot be correlated across service restarts.
|
||||
|
||||
When CoreWLAN cannot expose an association (for example because privacy
|
||||
authorization hides both identifiers), observations for the same
|
||||
interface/failure scope receive one process-scoped fallback token. The
|
||||
token is not association proof and callers must keep that distinction, but
|
||||
it prevents an unchanged kernel route from being misclassified as a new
|
||||
network on every monitor poll. Route loss, interface/source changes,
|
||||
recovery of exact BSSID evidence, or a service restart still changes the
|
||||
combined route fingerprint.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
helper_path: Path,
|
||||
*,
|
||||
runner: RunProcess = subprocess.run,
|
||||
continuity_key: bytes | None = None,
|
||||
max_cache_age_seconds: float = MAX_ASSOCIATION_OBSERVATION_CACHE_AGE_SECONDS,
|
||||
monotonic_clock: Callable[[], float] = time.monotonic,
|
||||
wall_clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
key = secrets.token_bytes(32) if continuity_key is None else bytes(continuity_key)
|
||||
if len(key) != 32:
|
||||
raise ValueError("host Wi-Fi continuity key must contain exactly 32 bytes")
|
||||
if not 0 <= max_cache_age_seconds <= MAX_ASSOCIATION_OBSERVATION_CACHE_AGE_SECONDS:
|
||||
raise ValueError(
|
||||
"host Wi-Fi association cache age must be between 0 and 0.75 seconds"
|
||||
)
|
||||
self._helper_path = helper_path
|
||||
self._runner = runner
|
||||
self._continuity_key = key
|
||||
self._max_cache_age_seconds = max_cache_age_seconds
|
||||
self._monotonic_clock = monotonic_clock
|
||||
self._wall_clock = wall_clock
|
||||
self._cached_observations: dict[
|
||||
str,
|
||||
tuple[float, float, HostWifiAssociationIdentityResult],
|
||||
] = {}
|
||||
self._last_interface_name: str | None = None
|
||||
self._fallback_tokens: dict[str, str] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def observe(
|
||||
self,
|
||||
interface_name: str | None,
|
||||
*,
|
||||
timeout_seconds: float = 30.0,
|
||||
) -> HostWifiAssociationIdentityResult:
|
||||
"""Return one opaque association token, failing closed when uncertain."""
|
||||
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
if interface_name is not None and (
|
||||
not 1 <= len(interface_name) <= 32
|
||||
or not all(
|
||||
character.isascii()
|
||||
and (character.isalnum() or character in "._-")
|
||||
for character in interface_name
|
||||
)
|
||||
):
|
||||
raise ValueError("host Wi-Fi interface name contains unsupported characters")
|
||||
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
if not self._lock.acquire(timeout=timeout_seconds):
|
||||
return self._fallback_without_lock(
|
||||
adapter="unavailable",
|
||||
wifi_interface=None,
|
||||
reason_code="host-wifi-operation-timeout",
|
||||
scope=f"interface:{interface_name or 'none'}",
|
||||
)
|
||||
try:
|
||||
if interface_name is None:
|
||||
self._invalidate_for_interface_transition_locked(None)
|
||||
return self._fallback_locked(
|
||||
adapter="unavailable",
|
||||
wifi_interface=None,
|
||||
reason_code="host-route-interface-unavailable",
|
||||
scope="interface:none",
|
||||
)
|
||||
self._invalidate_for_interface_transition_locked(interface_name)
|
||||
cached = self._cached_observation_locked(interface_name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
remaining_seconds = deadline - time.monotonic()
|
||||
if remaining_seconds <= 0:
|
||||
return self._remember_observation_locked(
|
||||
interface_name,
|
||||
self._fallback_locked(
|
||||
adapter="unavailable",
|
||||
wifi_interface=None,
|
||||
reason_code="host-wifi-operation-timeout",
|
||||
scope=f"interface:{interface_name}",
|
||||
),
|
||||
)
|
||||
try:
|
||||
response = _run_macos_helper(
|
||||
self._helper_path,
|
||||
{
|
||||
"action": "inspect-association",
|
||||
"profile_id": _ASSOCIATION_INSPECTION_PROFILE_ID,
|
||||
"interface_name": interface_name,
|
||||
"continuity_key_hex": self._continuity_key.hex(),
|
||||
},
|
||||
timeout_seconds=remaining_seconds,
|
||||
runner=self._runner,
|
||||
)
|
||||
except HostWifiProfileError as exc:
|
||||
return self._remember_observation_locked(
|
||||
interface_name,
|
||||
self._fallback_locked(
|
||||
adapter="unavailable",
|
||||
wifi_interface=None,
|
||||
reason_code=exc.reason_code,
|
||||
scope=f"interface:{interface_name}",
|
||||
),
|
||||
)
|
||||
|
||||
adapter = response.get("adapter")
|
||||
wifi_interface = response.get("wifi_interface")
|
||||
association_identity = response.get("association_identity")
|
||||
evidence_quality = response.get("association_evidence")
|
||||
reason_code = response.get("reason_code")
|
||||
if (
|
||||
not isinstance(adapter, str)
|
||||
or not adapter
|
||||
or not isinstance(wifi_interface, bool)
|
||||
or evidence_quality
|
||||
not in {"ssid+bssid", "bssid-only", "not-wifi", "unavailable"}
|
||||
or (reason_code is not None and not isinstance(reason_code, str))
|
||||
):
|
||||
return self._remember_observation_locked(
|
||||
interface_name,
|
||||
self._fallback_locked(
|
||||
adapter="unavailable",
|
||||
wifi_interface=None,
|
||||
reason_code="host-wifi-response-invalid",
|
||||
scope=f"interface:{interface_name}",
|
||||
),
|
||||
)
|
||||
|
||||
continuity_proven = evidence_quality in {
|
||||
"ssid+bssid",
|
||||
"bssid-only",
|
||||
"not-wifi",
|
||||
}
|
||||
if not continuity_proven:
|
||||
return self._remember_observation_locked(
|
||||
interface_name,
|
||||
self._fallback_locked(
|
||||
adapter=adapter,
|
||||
wifi_interface=wifi_interface,
|
||||
reason_code=reason_code or "association-identity-unavailable",
|
||||
scope=f"interface:{interface_name}",
|
||||
),
|
||||
)
|
||||
if (
|
||||
not isinstance(association_identity, str)
|
||||
or len(association_identity) != _ASSOCIATION_IDENTITY_HEX_LENGTH
|
||||
or any(character not in "0123456789abcdef" for character in association_identity)
|
||||
):
|
||||
return self._remember_observation_locked(
|
||||
interface_name,
|
||||
self._fallback_locked(
|
||||
adapter="unavailable",
|
||||
wifi_interface=wifi_interface,
|
||||
reason_code="host-wifi-response-invalid",
|
||||
scope=f"interface:{interface_name}",
|
||||
),
|
||||
)
|
||||
return self._remember_observation_locked(
|
||||
interface_name,
|
||||
{
|
||||
"schema_version": 1,
|
||||
"adapter": adapter,
|
||||
"wifi_interface": wifi_interface,
|
||||
"association_state": (
|
||||
"not-wifi" if evidence_quality == "not-wifi" else "associated"
|
||||
),
|
||||
"evidence_quality": evidence_quality,
|
||||
"continuity_proven": True,
|
||||
"continuity_token": association_identity,
|
||||
"reason_code": None,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
def _invalidate_for_interface_transition_locked(
|
||||
self,
|
||||
interface_name: str | None,
|
||||
) -> None:
|
||||
"""Make an interface transition an immediate cache barrier."""
|
||||
|
||||
if interface_name == self._last_interface_name:
|
||||
return
|
||||
self._cached_observations.clear()
|
||||
self._last_interface_name = interface_name
|
||||
|
||||
def _cached_observation_locked(
|
||||
self,
|
||||
interface_name: str,
|
||||
) -> HostWifiAssociationIdentityResult | None:
|
||||
"""Reuse only a sub-second read-only association observation.
|
||||
|
||||
Runtime compilation is deliberately not used here: the field-approved
|
||||
credential/CoreWLAN path relies on Apple's signed Swift interpreter
|
||||
identity. The cache is therefore limited to the read-only association
|
||||
result, never a credential or mutation result. Both clocks must remain
|
||||
monotonic within the 750 ms safety window; sleep, a wall-clock jump or
|
||||
an interface transition forces a fresh source-runner observation before
|
||||
the caller can publish.
|
||||
"""
|
||||
|
||||
cached = self._cached_observations.get(interface_name)
|
||||
if cached is None or self._max_cache_age_seconds == 0:
|
||||
return None
|
||||
observed_monotonic, observed_wall, observation = cached
|
||||
monotonic_age = self._monotonic_clock() - observed_monotonic
|
||||
wall_age = self._wall_clock() - observed_wall
|
||||
if (
|
||||
monotonic_age < 0
|
||||
or wall_age < 0
|
||||
or monotonic_age > self._max_cache_age_seconds
|
||||
or wall_age > self._max_cache_age_seconds
|
||||
):
|
||||
self._cached_observations.pop(interface_name, None)
|
||||
return None
|
||||
return observation.copy()
|
||||
|
||||
def _remember_observation_locked(
|
||||
self,
|
||||
interface_name: str,
|
||||
observation: HostWifiAssociationIdentityResult,
|
||||
) -> HostWifiAssociationIdentityResult:
|
||||
if self._max_cache_age_seconds > 0:
|
||||
self._cached_observations[interface_name] = (
|
||||
self._monotonic_clock(),
|
||||
self._wall_clock(),
|
||||
observation.copy(),
|
||||
)
|
||||
return observation.copy()
|
||||
|
||||
def _fallback_locked(
|
||||
self,
|
||||
*,
|
||||
adapter: str,
|
||||
wifi_interface: bool | None,
|
||||
reason_code: str,
|
||||
scope: str,
|
||||
) -> HostWifiAssociationIdentityResult:
|
||||
token_scope = "\x1f".join(
|
||||
(
|
||||
scope,
|
||||
adapter,
|
||||
"unknown" if wifi_interface is None else str(wifi_interface).lower(),
|
||||
reason_code,
|
||||
)
|
||||
)
|
||||
token = self._fallback_tokens.get(token_scope)
|
||||
if token is None:
|
||||
material = (
|
||||
"mission-core/host-wifi-association-fallback/v2\x1f"
|
||||
f"{token_scope}"
|
||||
).encode()
|
||||
token = hmac.new(self._continuity_key, material, hashlib.sha256).hexdigest()
|
||||
self._fallback_tokens[token_scope] = token
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"adapter": adapter,
|
||||
"wifi_interface": wifi_interface,
|
||||
"association_state": "unavailable",
|
||||
"evidence_quality": "unavailable",
|
||||
"continuity_proven": False,
|
||||
"continuity_token": token,
|
||||
"reason_code": reason_code,
|
||||
}
|
||||
|
||||
def _fallback_without_lock(
|
||||
self,
|
||||
*,
|
||||
adapter: str,
|
||||
wifi_interface: bool | None,
|
||||
reason_code: str,
|
||||
scope: str,
|
||||
) -> HostWifiAssociationIdentityResult:
|
||||
"""Build the deterministic process-local fallback under lock contention."""
|
||||
|
||||
token_scope = "\x1f".join(
|
||||
(
|
||||
scope,
|
||||
adapter,
|
||||
"unknown" if wifi_interface is None else str(wifi_interface).lower(),
|
||||
reason_code,
|
||||
)
|
||||
)
|
||||
material = (
|
||||
"mission-core/host-wifi-association-fallback/v2\x1f"
|
||||
f"{token_scope}"
|
||||
).encode()
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"adapter": adapter,
|
||||
"wifi_interface": wifi_interface,
|
||||
"association_state": "unavailable",
|
||||
"evidence_quality": "unavailable",
|
||||
"continuity_proven": False,
|
||||
"continuity_token": hmac.new(
|
||||
self._continuity_key,
|
||||
material,
|
||||
hashlib.sha256,
|
||||
).hexdigest(),
|
||||
"reason_code": reason_code,
|
||||
}
|
||||
|
||||
|
||||
def bind_route_fingerprint_to_wifi_association(
|
||||
route_fingerprint: str,
|
||||
association: HostWifiAssociationIdentityResult,
|
||||
) -> str:
|
||||
"""Bind route facts to one opaque association observation."""
|
||||
|
||||
if not route_fingerprint:
|
||||
raise ValueError("route fingerprint must be nonblank")
|
||||
token = association["continuity_token"]
|
||||
if (
|
||||
len(token) != _ASSOCIATION_IDENTITY_HEX_LENGTH
|
||||
or any(character not in "0123456789abcdef" for character in token)
|
||||
):
|
||||
raise ValueError("association continuity token is invalid")
|
||||
material = (
|
||||
"mission-core/host-route+wifi-association/v1\x1f"
|
||||
f"{route_fingerprint}\x1f{token}"
|
||||
)
|
||||
return hashlib.sha256(material.encode()).hexdigest()
|
||||
|
||||
|
||||
def associate_with_wifi_profile_once(
|
||||
helper_path: Path,
|
||||
profile_id: str,
|
||||
expected_ssid: str,
|
||||
*,
|
||||
scan_timeout_seconds: float = 15.0,
|
||||
scan_timeout_seconds: float = 30.0,
|
||||
timeout_seconds: float = 180.0,
|
||||
runner: RunProcess = subprocess.run,
|
||||
process_fence_descriptor_factory: Callable[[], int] | None = None,
|
||||
) -> HostWifiProfileAssociationResult:
|
||||
"""Associate through one device-scoped OS profile without exposing its secret.
|
||||
|
||||
The selected device supplies the expected, operator-visible AP SSID. The
|
||||
The selected device supplies the expected, operator-visible AP SSID. The
|
||||
platform helper performs bounded exact-SSID discovery followed by at most
|
||||
one association. It resolves a device-scoped secret from the OS credential
|
||||
stores, or asks for it through a native secure prompt on first use. The
|
||||
secret never crosses the helper boundary.
|
||||
one association using the already materialized Mission Core profile. It
|
||||
never falls back to another system credential or a post-write password
|
||||
prompt, and the secret never crosses the helper boundary.
|
||||
"""
|
||||
|
||||
_validate_profile_id(profile_id)
|
||||
@@ -422,6 +666,7 @@ def associate_with_wifi_profile_once(
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
runner=runner,
|
||||
process_fence_descriptor_factory=process_fence_descriptor_factory,
|
||||
)
|
||||
already_associated = response.get("already_associated")
|
||||
profile_enrolled = response.get("profile_enrolled")
|
||||
@@ -439,12 +684,7 @@ def associate_with_wifi_profile_once(
|
||||
or type(scan_elapsed_ms) is not int
|
||||
or scan_elapsed_ms < 0
|
||||
or not isinstance(credential_source, str)
|
||||
or credential_source not in {
|
||||
"mission-core-keychain",
|
||||
"system-wifi-keychain",
|
||||
"native-secure-prompt",
|
||||
"exact-firmware-profile",
|
||||
}
|
||||
or credential_source != "exact-firmware-profile"
|
||||
):
|
||||
raise HostWifiProfileError("host-wifi-response-invalid")
|
||||
return {
|
||||
@@ -459,6 +699,78 @@ def associate_with_wifi_profile_once(
|
||||
}
|
||||
|
||||
|
||||
def associate_with_ephemeral_wifi_credentials_once(
|
||||
helper_path: Path,
|
||||
expected_ssid: str,
|
||||
password: str,
|
||||
*,
|
||||
scan_timeout_seconds: float = 30.0,
|
||||
timeout_seconds: float = 180.0,
|
||||
runner: RunProcess = subprocess.run,
|
||||
process_fence_descriptor_factory: Callable[[], int] | None = None,
|
||||
) -> HostWifiProfileAssociationResult:
|
||||
"""Join one operator-selected Bridge network without persisting its secret.
|
||||
|
||||
This host-side handoff is used after K1 has already applied the Bridge
|
||||
topology and the Mac no longer has a direct route to it (most notably when
|
||||
switching from the K1 access point back to the shared network). The
|
||||
password crosses only the helper's stdin, is zeroed from the mutable Python
|
||||
request buffer, and is neither stored in the Mission Core Keychain profile
|
||||
store nor returned in evidence.
|
||||
|
||||
The call performs at most one CoreWLAN association after a bounded exact
|
||||
SSID scan. It never repeats the preceding BLE/device mutation.
|
||||
"""
|
||||
|
||||
if not 1 <= len(expected_ssid.encode("utf-8")) <= 32:
|
||||
raise ValueError("SSID must contain between 1 and 32 UTF-8 bytes")
|
||||
if not 1 <= len(password.encode("utf-8")) <= 64:
|
||||
raise ValueError("Wi-Fi password must contain between 1 and 64 UTF-8 bytes")
|
||||
if not 0 <= scan_timeout_seconds <= 60:
|
||||
raise ValueError("scan_timeout_seconds must be between 0 and 60")
|
||||
response = _run_macos_helper(
|
||||
helper_path,
|
||||
{
|
||||
"action": "associate-ephemeral",
|
||||
"profile_id": _EPHEMERAL_ASSOCIATION_PROFILE_ID,
|
||||
"ssid": expected_ssid,
|
||||
"password": password,
|
||||
"scan_timeout_seconds": scan_timeout_seconds,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
runner=runner,
|
||||
process_fence_descriptor_factory=process_fence_descriptor_factory,
|
||||
)
|
||||
already_associated = response.get("already_associated")
|
||||
profile_enrolled = response.get("profile_enrolled")
|
||||
scan_attempt_count = response.get("scan_attempt_count")
|
||||
scan_elapsed_ms = response.get("scan_elapsed_ms")
|
||||
credential_source = response.get("credential_source")
|
||||
adapter = response.get("adapter")
|
||||
if (
|
||||
not isinstance(already_associated, bool)
|
||||
or profile_enrolled is not False
|
||||
or not isinstance(adapter, str)
|
||||
or not adapter
|
||||
or type(scan_attempt_count) is not int
|
||||
or scan_attempt_count < 1
|
||||
or type(scan_elapsed_ms) is not int
|
||||
or scan_elapsed_ms < 0
|
||||
or credential_source != "operation-memory"
|
||||
):
|
||||
raise HostWifiProfileError("host-wifi-response-invalid")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"adapter": adapter,
|
||||
"outcome": "already-associated" if already_associated else "associated",
|
||||
"already_associated": already_associated,
|
||||
"profile_enrolled": False,
|
||||
"scan_attempt_count": scan_attempt_count,
|
||||
"scan_elapsed_ms": scan_elapsed_ms,
|
||||
"credential_source": "operation-memory",
|
||||
}
|
||||
|
||||
|
||||
def check_wifi_profile(
|
||||
helper_path: Path,
|
||||
profile_id: str,
|
||||
@@ -483,13 +795,27 @@ def check_wifi_profile(
|
||||
runner=runner,
|
||||
)
|
||||
available = response.get("profile_available")
|
||||
credential_source = response.get("credential_source")
|
||||
adapter = response.get("adapter")
|
||||
if not isinstance(available, bool) or not isinstance(adapter, str) or not adapter:
|
||||
if (
|
||||
not isinstance(available, bool)
|
||||
or not isinstance(adapter, str)
|
||||
or not adapter
|
||||
or (
|
||||
available
|
||||
and credential_source != "exact-firmware-profile"
|
||||
)
|
||||
or (
|
||||
not available
|
||||
and credential_source is not None
|
||||
)
|
||||
):
|
||||
raise HostWifiProfileError("host-wifi-response-invalid")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"adapter": adapter,
|
||||
"available": available,
|
||||
"credential_source": credential_source,
|
||||
}
|
||||
|
||||
|
||||
@@ -501,6 +827,7 @@ def ensure_wifi_profile_from_credential_source(
|
||||
*,
|
||||
timeout_seconds: float = 30.0,
|
||||
runner: RunProcess = subprocess.run,
|
||||
process_fence_descriptor_factory: Callable[[], int] | None = None,
|
||||
) -> HostWifiProfileEnsureResult:
|
||||
"""Materialize one device profile from a firmware-scoped secure-store item.
|
||||
|
||||
@@ -522,6 +849,7 @@ def ensure_wifi_profile_from_credential_source(
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
runner=runner,
|
||||
process_fence_descriptor_factory=process_fence_descriptor_factory,
|
||||
)
|
||||
available = response.get("profile_available")
|
||||
profile_enrolled = response.get("profile_enrolled")
|
||||
|
||||
Reference in New Issue
Block a user