diff --git a/README.md b/README.md index dc0672d..6c09a60 100644 --- a/README.md +++ b/README.md @@ -145,8 +145,12 @@ the response supplies live vendor identity, serial, model, activation and version facts for the BLE-selected K1. Static and wire evidence identify OpenAPI as one private application-level value in the observed client, not a manual per-scanner profile. The whole pre-START order and START/STOP payloads are -byte-matched offline, but there is no MQTT publisher, `vendor_writes_enabled` -remains false, and no application command is sent. +byte-matched offline. A fixed macOS Keychain loader now supplies the exact +application authority without env/file/browser fallback, and a five-batch +orchestrator passes all retained response barriers. The installed publisher +boundary is structurally write-disabled and cannot call its sink. There is no +MQTT publisher, `vendor_writes_enabled` remains false, and no application +command is sent. This locked bootstrap is repeatable in the current workspace, not yet a standalone release install. The frontend consumes sibling `file:` packages from diff --git a/docs/01_IMPLEMENTATION_PLAN.md b/docs/01_IMPLEMENTATION_PLAN.md index bb6c795..3c9ce01 100644 --- a/docs/01_IMPLEMENTATION_PLAN.md +++ b/docs/01_IMPLEMENTATION_PLAN.md @@ -21,7 +21,7 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result. | Stage 7 observation archive | GO (point/pose/telemetry contract) — durable catalog, recovery, capture-clock-bounded RRD preparation, archived metric time series, saved-session timeline and atomic playback are implemented | | Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; one real archived K1 camera plus point-cloud session has not passed playback yet | | Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process | -| K1 application control | PAUSE (exact shadow dialogue implemented) — all 10 retained pre-START requests and START/STOP are byte-matched; BLE-selected live DeviceInfo supplies per-scanner identity while one private application-level OpenAPI authority remains outside Git/Ops; publisher, physical save acceptance and recovery policy remain closed gates | +| K1 application control | PAUSE (offline execution gate complete) — exact five-batch response-barrier orchestrator passes the retained 10-request dialogue; the 36-byte application authority loads only from macOS Keychain; current publisher boundary is structurally write-disabled and never touches its sink; runtime wiring, physical START/STOP and durable save remain closed gates | | Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates | USB project copying remains optional ground truth rather than a blocker for the @@ -33,13 +33,17 @@ private application-level value in this LixelGO build, not a per-scanner credential. The selected BLE peripheral returns its own LAN IP; the initial unbound `DeviceInfoRequest` then returns vendor ID, serial, model, activation and version facts for that live K1. Mission Core's shadow bootstrap reproduces the -10 observed pre-START requests byte-for-byte: three unbound discovery reads, -one response-bound time/timezone sync and six further reads. A live-only safety -layer rejects identity drift or malformed status, and can form a non-executable -START only from unbound `READY` or STOP only from bound `SCANNING`. Public output -contains only digests and wire metadata. Secure application-authority loading, -an authorized response-gated one-shot publisher and durable post-stop save are -not yet proven. The physical button remains the known-safe fallback. +10 observed pre-START requests byte-for-byte across five recovered batches: +initial DeviceInfo binding; ModelingStatus plus RTK read; time sync plus +DeviceInfo/RTK; NTRIP read; then cloud/RTK/DeviceInfo reads. The first +ModelingStatus request has no mandatory synchronous response barrier; readiness +is separately attested through live DeviceStatus. A fixed macOS Keychain loader +now reads the exact 36-byte authority without environment/file/browser fallback. +The response orchestrator correlates required headers/results and rejects +identity/profile drift. The installed publish boundary is a separate +write-disabled type that cannot call its injected sink. Runtime wiring, a +reviewed write-capable type and durable post-stop save remain unproven. The +physical button remains the known-safe fallback. The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization queue cannot discard MQTT evidence. The queue holds four preview messages; @@ -258,7 +262,7 @@ useful stream is decoded or structurally identified. - firmware or internal-Linux analysis; - physical end-to-end shared-timeline playback for a newly archived session containing point cloud plus one selected K1 camera; -- private application-level OpenAPI authority loading, authorized command transport +- operator-owned Keychain provisioning/execution lease, authorized command transport and stable-artifact proof after the observed stop lifecycle; - long-running large-session WebViewer/WASM memory telemetry; - production retention, replication, encryption and cross-platform packaging. diff --git a/docs/05_K1_MQTT_STREAM_PROFILE.md b/docs/05_K1_MQTT_STREAM_PROFILE.md index 9e7e1e6..bddceaa 100644 --- a/docs/05_K1_MQTT_STREAM_PROFILE.md +++ b/docs/05_K1_MQTT_STREAM_PROFILE.md @@ -188,6 +188,15 @@ repository shadow bootstrap reproduces all ten retained payloads and their topic order byte-for-byte. Time sync is the only mutation before START; the other nine requests are reads. +The retained request/response timing further divides the dialogue into five +bounded batches. DeviceInfo response establishes identity before later bound +requests. Required response barriers are correlated by topic, exact session, +vendor identity, application authority and numeric success. The initial +ModelingStatus request is intentionally not treated as a required synchronous +response: the clean cycle continued without one, and readiness belongs to the +live DeviceStatus gate. The offline orchestrator completes over the retained +payloads without substitution or skipped requests. + The repository now contains an inert bounded encoder/response parser and a fail-closed device-status state machine for this exact profile. A live-only shadow planner accepts START only from `READY` with no project and STOP only @@ -195,9 +204,12 @@ from `SCANNING` with a bound project. It emits only a non-executable digest and wire metadata, uses QoS 2 with retain false, and declares automatic retry forbidden. It has no MQTT publish dependency and cannot send a command. -Publishing remains deliberately disabled: the private application authority -still needs a secure runtime loader and the complete stop/save sequence has not -physically proved a durable vendor project. A correlated STOP success, stream quiescence, return to +Publishing remains deliberately disabled. The exact 36-byte authority now has a +read-only macOS Keychain loader with no environment, file, browser or API +fallback, but it is not wired to the live facade. The current one-shot boundary +is structurally write-disabled and cannot touch its injected sink. The complete +stop/save sequence has not physically proved a durable vendor project. A +correlated STOP success, stream quiescence, return to `READY`/steady-green hardware state and appearance of a reusable native project are separate gates. The verified physical double-click remains the acquisition control until a separately reviewed one-shot write gate closes. diff --git a/docs/06_K1_LIVE_VIEWER.md b/docs/06_K1_LIVE_VIEWER.md index dd5a039..afdda92 100644 --- a/docs/06_K1_LIVE_VIEWER.md +++ b/docs/06_K1_LIVE_VIEWER.md @@ -148,7 +148,8 @@ observed pre-START requests plus START/STOP, correlates DeviceInfo and modeling responses, and classifies live device states without importing MQTT or publishing anything. One private application-level OpenAPI authority is kept separate from the transient vendor ID/serial returned by the BLE-selected K1. -Secure authority loading and durable save completion after stop remain open, so +The fixed Keychain loader and offline response orchestrator now exist, but live +execution-lease wiring and durable save completion after stop remain open, so `vendor_writes_enabled` is false and the physical-button workflow is canonical. ## Automatic Rerun source and lifecycle @@ -331,8 +332,8 @@ listener and its process memory must be closed unconditionally. separate generic media path. Historical sessions predating that archive have no recoverable video. - Physical double-click remains the K1 scan start/stop control. Any MQTT command - publisher needs reviewed private application-authority loading, response-gated - one-shot behavior and a proven durable-save completion gate. + publisher needs reviewed Keychain provisioning/execution-lease wiring, + response-gated one-shot behavior and a proven durable-save completion gate. - No terrain map, elevation model, obstacle segmentation, localization fusion, mission planner or vehicle control is implemented by this viewer milestone. - Exact coordinate axes and the scanner-to-vehicle transform remain a mounting diff --git a/docs/07_MISSION_CORE_MONOREPO.md b/docs/07_MISSION_CORE_MONOREPO.md index 93eac5e..4b6e66b 100644 --- a/docs/07_MISSION_CORE_MONOREPO.md +++ b/docs/07_MISSION_CORE_MONOREPO.md @@ -148,7 +148,8 @@ START/STOP, correlates live DeviceInfo/modeling responses and maps bounded device-status values into an observation-only state machine. The OpenAPI value is one private application-level authority; vendor ID and serial come from the BLE-selected live K1 and are cross-checked against status. No MQTT publisher -exists. Secure authority loading and stable-artifact proof after STOP remain +exists. A fixed Keychain loader and offline response orchestrator are present; +operator execution-lease wiring and stable-artifact proof after STOP remain unresolved, so `vendor_writes_enabled=false`. ## Semantic lifecycle @@ -221,8 +222,8 @@ views. 4. Physically accept a newly archived left/right K1 session, then package the read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media caching and evolve same-host MSE delivery toward an authenticated Edge media - plane. Keep the modeling-command publisher disabled until private application - authority loading and durable-save confirmation close its separate safety + plane. Keep the modeling-command publisher disabled until operator-owned + Keychain execution-lease wiring and durable-save confirmation close its safety gate. Complex equipment will likely be assembled from separately useful component diff --git a/docs/09_OBSERVATION_SESSIONS.md b/docs/09_OBSERVATION_SESSIONS.md index 747dcf0..58b895a 100644 --- a/docs/09_OBSERVATION_SESSIONS.md +++ b/docs/09_OBSERVATION_SESSIONS.md @@ -513,5 +513,5 @@ viewer/WASM payload. That is a packaging optimization item, not a failed gate. No retained physical K1 session contains the new canonical camera archive, so a real point-cloud plus one-camera recorded playback remains an explicit hardware acceptance test. Automated protocol tests also do not authorize K1 modeling -publishing: private application-authority loading and durable save completion +publishing: operator-owned Keychain execution lease and durable save completion remain separate physical/security gates. diff --git a/docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md b/docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md index 292abbe..0f9564e 100644 --- a/docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md +++ b/docs/adr/0009-device-plugin-observation-runtime-and-k1-extraction.md @@ -114,7 +114,7 @@ The operator project name is normalized and validated by the K1 contribution, stored as display/catalog metadata and never used as a path component. This ADR does not claim that it reaches the scanner: the inert application-control codec has no publisher, and automatic K1 writes remain disabled pending reviewed -private application-authority loading and durable post-stop save evidence. +operator-owned Keychain execution-lease wiring and durable post-stop save evidence. ADR 0011 subsequently places the action control plane behind a versioned descriptor/handshake/health transport seam. Observation discovery and export diff --git a/docs/adr/0012-device-bound-k1-command-authority.md b/docs/adr/0012-device-bound-k1-command-authority.md index ece15cc..46eef8e 100644 --- a/docs/adr/0012-device-bound-k1-command-authority.md +++ b/docs/adr/0012-device-bound-k1-command-authority.md @@ -77,24 +77,30 @@ captured special relation ## Current implementation boundary -`application_bootstrap.py` provides the bounded application authority type, -DeviceInfo response correlation/decoding and the exact non-executable ten-step -shadow plan. Its retained clean-cycle regression is 10/10 payloads and topic -order. `LiveModelingControlSafety` independently binds that DeviceInfo identity -to live status and creates non-executable START/STOP plans. +`application_bootstrap.py` provides DeviceInfo decoding, the exact ten-request +shadow plan and a five-batch response-barrier orchestrator. Its private retained +regression completes with 10/10 request payload/topic matches and correlated +required responses. `ModelingStatusRequest` remains non-blocking exactly where +the capture had no synchronous response. `application_authority.py` reads the +exact 36-byte authority only from a fixed macOS Keychain item and offers no +plaintext fallback. `LiveModelingControlSafety` independently binds DeviceInfo +identity to live status and creates non-executable START/STOP plans. Public state contains only booleans, counts and wire metadata. Private identity, -authority and payload bytes stay out of repr/API output. Both shadow paths are -blocked by `vendor-writes-disabled` and `publisher-not-installed`; no MQTT -publisher or automatic retry path exists. +authority and payload bytes stay out of repr/API output. +`application_publish.py` defines reviewed one-shot envelopes plus a current +write-disabled publisher type. That type has no enable switch and raises before +its injected transport sink can be called. Both shadow paths remain blocked by +`vendor-writes-disabled` and `publisher-not-installed`; no MQTT publisher or +automatic retry path exists. ## Promotion gate A future publisher requires separate review and an operator-present physical acceptance: -1. load the private application authority through a reviewed local secret - mechanism; +1. provision the reviewed private authority into the fixed local Keychain item + and load it for one operator-owned execution lease; 2. confirm battery/storage and select one K1 over BLE; 3. run the exact response-gated bootstrap and attest activated FW 3.0.2; 4. confirm READY identity against DeviceInfo and review shadow metadata; diff --git a/plugins/xgrids-k1/profiles/README.md b/plugins/xgrids-k1/profiles/README.md index 4315d04..2c3dec5 100644 --- a/plugins/xgrids-k1/profiles/README.md +++ b/plugins/xgrids-k1/profiles/README.md @@ -38,9 +38,10 @@ The v1 loader rejects every `write_enabled: true`. Owner-operated LixelGO wire capture verifies the `ModelingRequest` topic, action values, field layout, literal `{device_id}:ModelingRequest` session relation, retained start settings and numeric success code. Retained client/wire evidence identifies OpenAPI as -one private application-level value rather than a per-scanner profile. Its -secure runtime loading remains unresolved, as do durable save completion, -timeout and rollback behavior. Acquisition therefore +one private application-level value rather than a per-scanner profile. A fixed +read-only macOS Keychain loader and offline response orchestrator now exist; +operator-owned provisioning/execution-lease wiring, durable save completion, +timeout and rollback behavior remain unresolved. Acquisition therefore stays `operator-manual` through the verified physical double-click. The standalone encoder models the recovered wire schema, including enum values outside the retained request. It is not an authorization policy: any future diff --git a/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json b/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json index d8aaa52..4615bab 100644 --- a/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json +++ b/plugins/xgrids-k1/profiles/fw-3.0.2/direct-lan.v1.json @@ -359,7 +359,7 @@ }, "success_result_code": 302252033, "required_unresolved_context": [ - "secure private application-authority loading", + "operator-owned Keychain authority provisioning and execution-lease wiring", "authorization policy for any setting outside the retained request", "timeout, rejection and rollback contract" ], @@ -406,7 +406,7 @@ "request_fields": {}, "success_result_code": 302252033, "required_unresolved_context": [ - "secure private application-authority loading", + "operator-owned Keychain authority provisioning and execution-lease wiring", "save-completion and final-standby state mapping", "timeout and rollback contract" ], diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/application_authority.py b/src/k1link/device_plugins/xgrids_k1/protocol/application_authority.py new file mode 100644 index 0000000..a259e35 --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/protocol/application_authority.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import platform +import shutil +import subprocess +from dataclasses import dataclass +from typing import Protocol + +from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( + ApplicationBootstrapError, + ApplicationControlAuthority, +) + +KEYCHAIN_SERVICE = "NODEDC Mission Core XGRIDS K1 OpenAPI" +KEYCHAIN_ACCOUNT = "lixelgo-application-fw-3.0.2" +KEYCHAIN_TIMEOUT_SECONDS = 5.0 + + +class ApplicationAuthorityLoadError(RuntimeError): + """The private application authority could not be loaded safely.""" + + +class CommandRunner(Protocol): + def __call__( + self, + args: list[str], + *, + capture_output: bool, + check: bool, + timeout: float, + ) -> subprocess.CompletedProcess[bytes]: ... + + +@dataclass(frozen=True, slots=True) +class ApplicationAuthoritySourceSnapshot: + provider: str = "macos-keychain" + service: str = KEYCHAIN_SERVICE + account: str = KEYCHAIN_ACCOUNT + secret_cached: bool = False + secret_exportable: bool = False + file_fallback_enabled: bool = False + environment_fallback_enabled: bool = False + + def as_dict(self) -> dict[str, object]: + return { + "provider": self.provider, + "service": self.service, + "account": self.account, + "secret_cached": self.secret_cached, + "secret_exportable": self.secret_exportable, + "file_fallback_enabled": self.file_fallback_enabled, + "environment_fallback_enabled": self.environment_fallback_enabled, + } + + +class MacOSKeychainApplicationAuthorityLoader: + """Read the exact-profile authority from the current user's Keychain. + + There is deliberately no environment, plaintext-file, browser or API + fallback. The secret is requested through the absolute ``security`` binary + and is never interpolated into an exception, repr or subprocess argument. + """ + + def __init__(self, *, runner: CommandRunner = subprocess.run) -> None: + self._runner = runner + + def snapshot(self) -> ApplicationAuthoritySourceSnapshot: + return ApplicationAuthoritySourceSnapshot() + + def load(self) -> ApplicationControlAuthority: + if platform.system() != "Darwin": + raise ApplicationAuthorityLoadError( + "application authority loading is supported only through macOS Keychain" + ) + security = shutil.which("security") + if security != "/usr/bin/security": + raise ApplicationAuthorityLoadError("trusted macOS security binary is unavailable") + + try: + completed = self._runner( + [ + security, + "find-generic-password", + "-s", + KEYCHAIN_SERVICE, + "-a", + KEYCHAIN_ACCOUNT, + "-w", + ], + capture_output=True, + check=False, + timeout=KEYCHAIN_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed") from exc + + if completed.returncode != 0: + raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable") + + secret_buffer = bytearray(completed.stdout) + try: + while secret_buffer.endswith((b"\n", b"\r")): + secret_buffer.pop() + try: + secret = secret_buffer.decode("ascii") + except UnicodeDecodeError as exc: + raise ApplicationAuthorityLoadError( + "macOS Keychain authority has an invalid encoding" + ) from exc + try: + return ApplicationControlAuthority(openapi_key=secret) + except ApplicationBootstrapError as exc: + raise ApplicationAuthorityLoadError( + "macOS Keychain authority does not match the reviewed profile" + ) from exc + finally: + for index in range(len(secret_buffer)): + secret_buffer[index] = 0 diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/application_bootstrap.py b/src/k1link/device_plugins/xgrids_k1/protocol/application_bootstrap.py index 4c70784..23ec2db 100644 --- a/src/k1link/device_plugins/xgrids_k1/protocol/application_bootstrap.py +++ b/src/k1link/device_plugins/xgrids_k1/protocol/application_bootstrap.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib import hmac import re +import threading from dataclasses import dataclass, field from typing import Literal @@ -62,6 +63,10 @@ class ApplicationControlAuthority: "application authority does not match the reviewed K1 profile" ) _validate_ascii_identity(self.openapi_key, "openapi_key") + if len(self.openapi_key) != 36: + raise ApplicationBootstrapError( + "openapi_key must match the exact 36-byte reviewed application profile" + ) @dataclass(frozen=True, slots=True) @@ -133,6 +138,9 @@ class EncodedApplicationRequest: payload_bytes: int mutates_device: bool requires_live_binding: bool + response_required: bool + session_id: str = field(repr=False) + vendor_device_id: str | None = field(repr=False) qos: int = 2 retain: bool = False @@ -147,6 +155,7 @@ class EncodedApplicationRequest: "payload_bytes": self.payload_bytes, "mutates_device": self.mutates_device, "requires_live_binding": self.requires_live_binding, + "response_required": self.response_required, "qos": self.qos, "retain": self.retain, } @@ -181,6 +190,39 @@ class DeviceInfoResponse: result_code: int +@dataclass(frozen=True, slots=True) +class ApplicationResponse: + session_id: str = field(repr=False) + vendor_device_id: str = field(repr=False) + openapi_key: str = field(repr=False) + result_code: int + + +@dataclass(frozen=True, slots=True) +class ApplicationBootstrapSnapshot: + current_batch: int + total_batches: int + batch_issued: bool + pending_response_topics: tuple[str, ...] + live_binding_observed: bool + bootstrap_complete: bool + executable: bool = False + vendor_writes_enabled: bool = False + + def as_dict(self) -> dict[str, object]: + return { + "mode": "shadow-only", + "current_batch": self.current_batch, + "total_batches": self.total_batches, + "batch_issued": self.batch_issued, + "pending_response_topics": list(self.pending_response_topics), + "live_binding_observed": self.live_binding_observed, + "bootstrap_complete": self.bootstrap_complete, + "executable": self.executable, + "vendor_writes_enabled": self.vendor_writes_enabled, + } + + def build_shadow_bootstrap( authority: ApplicationControlAuthority, binding: LiveDeviceControlBinding, @@ -213,6 +255,7 @@ def build_shadow_bootstrap( DEVICE_INFO_RESPONSE_TOPIC, None, False, + True, ), ( "identity-discovery", @@ -221,6 +264,7 @@ def build_shadow_bootstrap( MODELING_STATUS_RESPONSE_TOPIC, None, False, + False, ), ( "identity-discovery", @@ -229,6 +273,7 @@ def build_shadow_bootstrap( GET_RTK_ADVANCE_RESPONSE_TOPIC, None, False, + True, ), ( "bound-preparation", @@ -237,6 +282,7 @@ def build_shadow_bootstrap( DEVICE_CONFIG_RESPONSE_TOPIC, DEVICE_CONFIG_TIME_CONTEXT, True, + True, ), ( "bound-preparation", @@ -245,6 +291,7 @@ def build_shadow_bootstrap( DEVICE_INFO_RESPONSE_TOPIC, None, False, + True, ), ( "bound-preparation", @@ -253,6 +300,7 @@ def build_shadow_bootstrap( GET_RTK_ADVANCE_RESPONSE_TOPIC, None, False, + True, ), ( "bound-preparation", @@ -261,6 +309,7 @@ def build_shadow_bootstrap( GET_NTRIP_PROFILE_RESPONSE_TOPIC, None, False, + True, ), ( "bound-preparation", @@ -269,6 +318,7 @@ def build_shadow_bootstrap( GET_CLOUD_CONFIG_RESPONSE_TOPIC, None, False, + True, ), ( "bound-preparation", @@ -277,6 +327,7 @@ def build_shadow_bootstrap( GET_RTK_ADVANCE_RESPONSE_TOPIC, None, False, + True, ), ( "bound-preparation", @@ -285,12 +336,19 @@ def build_shadow_bootstrap( DEVICE_INFO_RESPONSE_TOPIC, None, False, + True, ), ) requests: list[EncodedApplicationRequest] = [] - for ordinal, (phase, message_type, topic, response_topic, context, mutates) in enumerate( - specs, start=1 - ): + for ordinal, ( + phase, + message_type, + topic, + response_topic, + context, + mutates, + response_required, + ) in enumerate(specs, start=1): request_binding = None if ordinal <= 3 else binding header = ApplicationRequestHeader( message_type=message_type, @@ -316,6 +374,11 @@ def build_shadow_bootstrap( payload_bytes=len(payload), mutates_device=mutates, requires_live_binding=ordinal > 3, + response_required=response_required, + session_id=header.session_id, + vendor_device_id=( + request_binding.vendor_device_id if request_binding is not None else None + ), ) ) return ShadowApplicationBootstrap(tuple(requests)) @@ -324,6 +387,9 @@ def build_shadow_bootstrap( def decode_and_bind_device_info_response( payload: bytes, authority: ApplicationControlAuthority, + *, + expected_session_id: str = ":DeviceInfoRequest", + expected_vendor_device_id: str | None = None, ) -> DeviceInfoResponse: """Correlate the bootstrap response and derive a live per-device binding.""" @@ -333,8 +399,12 @@ def decode_and_bind_device_info_response( device_id = _identity_field(header, 4, "header.device_id") session_id = _identity_field(header, 5, "header.session_id") openapi_key = _identity_field(header, 6, "header.openapi_key") - if session_id != ":DeviceInfoRequest": + if not hmac.compare_digest(session_id, expected_session_id): raise ApplicationBootstrapError("DeviceInfoResponse session correlation failed") + if expected_vendor_device_id is not None and not hmac.compare_digest( + device_id, expected_vendor_device_id + ): + raise ApplicationBootstrapError("DeviceInfoResponse vendor identity mismatch") if not hmac.compare_digest(openapi_key, authority.openapi_key): raise ApplicationBootstrapError("DeviceInfoResponse application authority mismatch") @@ -371,6 +441,221 @@ def decode_and_bind_device_info_response( return DeviceInfoResponse(binding, session_id, openapi_key, result_code) +def correlate_application_response( + payload: bytes, + expected: EncodedApplicationRequest, + authority: ApplicationControlAuthority, + *, + live_binding: LiveDeviceControlBinding | None = None, +) -> ApplicationResponse: + """Correlate a non-DeviceInfo response to an exact emitted request.""" + + expected_device_id = expected.vendor_device_id + if expected_device_id is None and live_binding is not None: + expected_device_id = live_binding.vendor_device_id + if expected_device_id is None: + raise ApplicationBootstrapError( + "generic response correlation requires live DeviceInfo identity" + ) + _check_payload(payload, "application response") + top = _selected_unique_fields( + payload, + "application response", + max_fields=64, + selected={1, 15}, + ) + header = _selected_unique_fields( + _required_bytes(top, 1, "response.header"), + "header", + max_fields=32, + selected={4, 5, 6}, + ) + device_id = _identity_field(header, 4, "header.device_id") + session_id = _identity_field(header, 5, "header.session_id") + openapi_key = _identity_field(header, 6, "header.openapi_key") + if not hmac.compare_digest(device_id, expected_device_id): + raise ApplicationBootstrapError("application response vendor identity mismatch") + if not hmac.compare_digest(session_id, expected.session_id): + raise ApplicationBootstrapError("application response session correlation failed") + if not hmac.compare_digest(openapi_key, authority.openapi_key): + raise ApplicationBootstrapError("application response authority mismatch") + + error = _selected_unique_fields( + _required_bytes(top, 15, "response.error"), + "error", + max_fields=8, + selected={1, 2}, + ) + result_code = _required_uint(error, 1, "response.error.code") + if result_code != OPENAPI_SUCCESS: + raise ApplicationBootstrapError( + f"application response rejected with result code {result_code}" + ) + return ApplicationResponse(session_id, device_id, openapi_key, result_code) + + +class ShadowApplicationBootstrapOrchestrator: + """Advance the retained dialogue only across observed response barriers. + + The clean cycle emitted five batches. ``ModelingStatusRequest`` in the + second batch had no required synchronous response before preparation + continued; readiness remains a separate live DeviceStatus gate. + """ + + _BATCH_ORDINALS = ((1,), (2, 3), (4, 5, 6), (7,), (8, 9, 10)) + + def __init__( + self, + authority: ApplicationControlAuthority, + *, + epoch_seconds: int, + timezone_name: str, + ) -> None: + self._lock = threading.Lock() + self._authority = authority + self._epoch_seconds = epoch_seconds + self._timezone_name = timezone_name + self._binding: LiveDeviceControlBinding | None = None + self._plan: ShadowApplicationBootstrap | None = None + self._batch_index = 0 + self._batch_issued = False + self._pending: dict[str, EncodedApplicationRequest] = {} + self._complete = False + self._initial_request = _build_initial_device_info_request(authority) + + def snapshot(self) -> ApplicationBootstrapSnapshot: + with self._lock: + return ApplicationBootstrapSnapshot( + current_batch=min(self._batch_index + 1, len(self._BATCH_ORDINALS)), + total_batches=len(self._BATCH_ORDINALS), + batch_issued=self._batch_issued, + pending_response_topics=tuple(sorted(self._pending)), + live_binding_observed=self._binding is not None, + bootstrap_complete=self._complete, + ) + + @property + def binding(self) -> LiveDeviceControlBinding | None: + with self._lock: + return self._binding + + def next_batch(self) -> tuple[EncodedApplicationRequest, ...]: + """Return the next batch once; repeated access fails closed.""" + + with self._lock: + if self._complete: + raise ApplicationBootstrapError("application bootstrap is already complete") + if self._batch_issued: + raise ApplicationBootstrapError( + "current application bootstrap batch was already issued" + ) + requests: tuple[EncodedApplicationRequest, ...] + if self._batch_index == 0: + requests = (self._initial_request,) + else: + if self._plan is None: + raise ApplicationBootstrapError("live DeviceInfo binding is missing") + requests = tuple( + self._plan.requests[ordinal - 1] + for ordinal in self._BATCH_ORDINALS[self._batch_index] + ) + pending = { + request.response_topic: request for request in requests if request.response_required + } + if len(pending) != sum(request.response_required for request in requests): + raise ApplicationBootstrapError( + "bootstrap batch contains ambiguous required response topics" + ) + if not pending: + raise ApplicationBootstrapError("bootstrap batch has no recovered response barrier") + self._pending = pending + self._batch_issued = True + return requests + + def accept_response(self, topic: str, payload: bytes) -> None: + """Correlate one required response and unlock only the next batch.""" + + with self._lock: + if not self._batch_issued: + raise ApplicationBootstrapError("no application bootstrap batch is in flight") + expected = self._pending.get(topic) + if expected is None: + raise ApplicationBootstrapError( + "application response is not required by the current batch" + ) + + if expected.ordinal == 1: + response = decode_and_bind_device_info_response(payload, self._authority) + binding = response.binding + plan = build_shadow_bootstrap( + self._authority, + binding, + epoch_seconds=self._epoch_seconds, + timezone_name=self._timezone_name, + ) + if plan.requests[0].payload != self._initial_request.payload: + raise ApplicationBootstrapError( + "bound bootstrap plan changed the emitted identity request" + ) + self._binding = binding + self._plan = plan + else: + if self._binding is None: + raise ApplicationBootstrapError("live DeviceInfo binding is missing") + if expected.message_type == "DeviceInfoRequest": + response = decode_and_bind_device_info_response( + payload, + self._authority, + expected_session_id=expected.session_id, + expected_vendor_device_id=self._binding.vendor_device_id, + ) + if response.binding != self._binding: + raise ApplicationBootstrapError( + "live DeviceInfo facts changed during bootstrap" + ) + else: + correlate_application_response( + payload, + expected, + self._authority, + live_binding=self._binding, + ) + + del self._pending[topic] + if self._pending: + return + self._batch_issued = False + self._batch_index += 1 + if self._batch_index == len(self._BATCH_ORDINALS): + self._complete = True + + +def _build_initial_device_info_request( + authority: ApplicationControlAuthority, +) -> EncodedApplicationRequest: + header = ApplicationRequestHeader( + message_type="DeviceInfoRequest", + authority=authority, + ) + payload = _bytes_field(1, _encode_header(header)) + _check_payload(payload, "application request") + return EncodedApplicationRequest( + ordinal=1, + phase="identity-discovery", + message_type="DeviceInfoRequest", + topic=DEVICE_INFO_REQUEST_TOPIC, + response_topic=DEVICE_INFO_RESPONSE_TOPIC, + payload=payload, + payload_sha256=hashlib.sha256(payload).hexdigest(), + payload_bytes=len(payload), + mutates_device=False, + requires_live_binding=False, + response_required=True, + session_id=header.session_id, + vendor_device_id=None, + ) + + def _encode_header(header: ApplicationRequestHeader) -> bytes: parts: list[bytes] = [] if header.binding is not None: @@ -438,6 +723,26 @@ def _unique_fields( return result +def _selected_unique_fields( + payload: bytes, + name: str, + *, + max_fields: int, + selected: set[int], +) -> dict[int, ProtoField]: + result: dict[int, ProtoField] = {} + try: + for item in iter_fields(payload, max_fields=max_fields): + if item.number not in selected: + continue + if item.number in result: + raise ApplicationBootstrapError(f"{name} field {item.number} is duplicated") + result[item.number] = item + except ProtobufWireError as exc: + raise ApplicationBootstrapError(f"invalid {name}: {exc}") from exc + return result + + def _required_bytes(fields: dict[int, ProtoField], number: int, name: str) -> bytes: item = fields.get(number) if item is None or item.wire_type != 2 or not isinstance(item.value, bytes): diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/application_publish.py b/src/k1link/device_plugins/xgrids_k1/protocol/application_publish.py new file mode 100644 index 0000000..d7db0a1 --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/protocol/application_publish.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import hashlib +import threading +from dataclasses import dataclass, field +from typing import Protocol + +from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( + EncodedApplicationRequest, +) +from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import ( + ShadowModelingCommand, +) + + +class VendorWritesDisabledError(RuntimeError): + """The active compatibility profile cannot touch a publish transport.""" + + +class ApplicationPublishSink(Protocol): + def publish( + self, + *, + topic: str, + payload: bytes, + qos: int, + retain: bool, + ) -> None: ... + + +@dataclass(frozen=True, slots=True) +class OneShotPublishEnvelope: + topic: str + payload: bytes = field(repr=False) + payload_sha256: str + payload_bytes: int + qos: int + retain: bool + automatic_retry: bool = False + + @classmethod + def from_bootstrap_request( + cls, + request: EncodedApplicationRequest, + ) -> OneShotPublishEnvelope: + return cls( + topic=request.topic, + payload=request.payload, + payload_sha256=request.payload_sha256, + payload_bytes=request.payload_bytes, + qos=request.qos, + retain=request.retain, + ) + + @classmethod + def from_modeling_command( + cls, + command: ShadowModelingCommand, + ) -> OneShotPublishEnvelope: + return cls( + topic=command.topic, + payload=command.command.payload, + payload_sha256=command.payload_sha256, + payload_bytes=command.payload_bytes, + qos=command.qos, + retain=command.retain, + ) + + def __post_init__(self) -> None: + if self.qos != 2 or self.retain or self.automatic_retry: + raise ValueError("publish envelope differs from the reviewed one-shot contract") + if self.payload_bytes != len(self.payload): + raise ValueError("publish envelope payload length mismatch") + if not hashlib.sha256(self.payload).hexdigest() == self.payload_sha256: + raise ValueError("publish envelope payload digest mismatch") + + def as_dict(self) -> dict[str, object]: + return { + "topic": self.topic, + "payload_sha256": self.payload_sha256, + "payload_bytes": self.payload_bytes, + "qos": self.qos, + "retain": self.retain, + "automatic_retry": self.automatic_retry, + } + + +@dataclass(frozen=True, slots=True) +class WriteDisabledPublisherSnapshot: + denied_attempts: int + transport_calls: int = 0 + vendor_writes_enabled: bool = False + publisher_armed: bool = False + automatic_retry: bool = False + + def as_dict(self) -> dict[str, object]: + return { + "mode": "write-disabled", + "denied_attempts": self.denied_attempts, + "transport_calls": self.transport_calls, + "vendor_writes_enabled": self.vendor_writes_enabled, + "publisher_armed": self.publisher_armed, + "automatic_retry": self.automatic_retry, + } + + +class WriteDisabledOneShotPublisher: + """Current-profile publish boundary that cannot call its injected sink. + + A future write-capable implementation must be a separate reviewed type. It + cannot be enabled by flipping a runtime boolean on this class. + """ + + def __init__(self, sink: ApplicationPublishSink) -> None: + self._sink = sink + self._lock = threading.Lock() + self._denied_attempts = 0 + + def publish_once(self, _envelope: OneShotPublishEnvelope) -> None: + with self._lock: + self._denied_attempts += 1 + raise VendorWritesDisabledError( + "active K1 compatibility profile cannot publish vendor requests" + ) + + def snapshot(self) -> WriteDisabledPublisherSnapshot: + with self._lock: + return WriteDisabledPublisherSnapshot(denied_attempts=self._denied_attempts) diff --git a/tests/test_xgrids_application_authority.py b/tests/test_xgrids_application_authority.py new file mode 100644 index 0000000..e3082d4 --- /dev/null +++ b/tests/test_xgrids_application_authority.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import subprocess +from unittest.mock import patch + +import pytest + +from k1link.device_plugins.xgrids_k1.protocol.application_authority import ( + KEYCHAIN_ACCOUNT, + KEYCHAIN_SERVICE, + ApplicationAuthorityLoadError, + MacOSKeychainApplicationAuthorityLoader, +) + +PRIVATE_AUTHORITY = b"11111111-2222-3333-4444-555555555555\n" + + +@patch("platform.system", return_value="Darwin") +@patch("shutil.which", return_value="/usr/bin/security") +def test_authority_loads_only_from_fixed_macos_keychain_item( + _which: object, + _system: object, +) -> None: + calls: list[list[str]] = [] + + def runner( + args: list[str], + *, + capture_output: bool, + check: bool, + timeout: float, + ) -> subprocess.CompletedProcess[bytes]: + calls.append(args) + assert capture_output + assert check is False + assert timeout == 5.0 + return subprocess.CompletedProcess(args, 0, stdout=PRIVATE_AUTHORITY, stderr=b"") + + loader = MacOSKeychainApplicationAuthorityLoader(runner=runner) + authority = loader.load() + + assert calls == [ + [ + "/usr/bin/security", + "find-generic-password", + "-s", + KEYCHAIN_SERVICE, + "-a", + KEYCHAIN_ACCOUNT, + "-w", + ] + ] + assert PRIVATE_AUTHORITY.decode().strip() not in repr(authority) + assert loader.snapshot().as_dict() == { + "provider": "macos-keychain", + "service": KEYCHAIN_SERVICE, + "account": KEYCHAIN_ACCOUNT, + "secret_cached": False, + "secret_exportable": False, + "file_fallback_enabled": False, + "environment_fallback_enabled": False, + } + + +@patch("platform.system", return_value="Linux") +def test_authority_loader_has_no_non_keychain_fallback(_system: object) -> None: + loader = MacOSKeychainApplicationAuthorityLoader() + + with pytest.raises(ApplicationAuthorityLoadError, match="only through macOS Keychain"): + loader.load() + + +@patch("platform.system", return_value="Darwin") +@patch("shutil.which", return_value="/usr/bin/security") +def test_authority_loader_redacts_keychain_failures( + _which: object, + _system: object, +) -> None: + private_error = b"private-keychain-diagnostic" + + def runner( + args: list[str], + **_kwargs: object, + ) -> subprocess.CompletedProcess[bytes]: + return subprocess.CompletedProcess(args, 44, stdout=b"", stderr=private_error) + + with pytest.raises(ApplicationAuthorityLoadError) as error: + MacOSKeychainApplicationAuthorityLoader(runner=runner).load() + + assert private_error.decode() not in str(error.value) + + +@patch("platform.system", return_value="Darwin") +@patch("shutil.which", return_value="/usr/bin/security") +def test_authority_loader_rejects_wrong_profile_length_without_reflection( + _which: object, + _system: object, +) -> None: + invalid_secret = b"too-short-private-value" + + def runner( + args: list[str], + **_kwargs: object, + ) -> subprocess.CompletedProcess[bytes]: + return subprocess.CompletedProcess(args, 0, stdout=invalid_secret, stderr=b"") + + with pytest.raises(ApplicationAuthorityLoadError) as error: + MacOSKeychainApplicationAuthorityLoader(runner=runner).load() + + assert invalid_secret.decode() not in str(error.value) diff --git a/tests/test_xgrids_application_bootstrap.py b/tests/test_xgrids_application_bootstrap.py index 5655523..3ef7eba 100644 --- a/tests/test_xgrids_application_bootstrap.py +++ b/tests/test_xgrids_application_bootstrap.py @@ -6,6 +6,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( ApplicationBootstrapError, ApplicationControlAuthority, LiveDeviceControlBinding, + ShadowApplicationBootstrapOrchestrator, build_shadow_bootstrap, decode_and_bind_device_info_response, ) @@ -75,6 +76,13 @@ def _device_info_response(*, session_id: str = ":DeviceInfoRequest") -> bytes: return _bytes(1, header) + _bytes(2, device_info) + _bytes(15, error) +def _generic_response(session_id: str) -> bytes: + header = _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY) + error = _uint(1, OPENAPI_SUCCESS) + _text(2, "success") + repeated_vendor_body = _text(3, "opaque-a") + _text(3, "opaque-b") + return _bytes(1, header) + repeated_vendor_body + _bytes(15, error) + + def test_exact_clean_cycle_pre_start_order_and_wire_lengths() -> None: plan = build_shadow_bootstrap( _authority(), @@ -165,3 +173,89 @@ def test_device_info_response_correlation_and_profile_attestation_fail_closed() epoch_seconds=1_752_680_000, timezone_name="Europe/Moscow", ) + + +def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None: + orchestrator = ShadowApplicationBootstrapOrchestrator( + _authority(), + epoch_seconds=1_752_680_000, + timezone_name="Europe/Moscow", + ) + + identity = orchestrator.next_batch() + assert [request.ordinal for request in identity] == [1] + with pytest.raises(ApplicationBootstrapError, match="already issued"): + orchestrator.next_batch() + orchestrator.accept_response(identity[0].response_topic, _device_info_response()) + + initial_reads = orchestrator.next_batch() + assert [request.ordinal for request in initial_reads] == [2, 3] + assert [request.response_required for request in initial_reads] == [False, True] + orchestrator.accept_response( + initial_reads[1].response_topic, + _generic_response(initial_reads[1].session_id), + ) + + preparation = orchestrator.next_batch() + assert [request.ordinal for request in preparation] == [4, 5, 6] + device_info = preparation[1] + orchestrator.accept_response( + device_info.response_topic, + _device_info_response(session_id=device_info.session_id), + ) + with pytest.raises(ApplicationBootstrapError, match="already issued"): + orchestrator.next_batch() + for request in (preparation[2], preparation[0]): + orchestrator.accept_response( + request.response_topic, + _generic_response(request.session_id), + ) + + ntrip = orchestrator.next_batch() + assert [request.ordinal for request in ntrip] == [7] + orchestrator.accept_response( + ntrip[0].response_topic, + _generic_response(ntrip[0].session_id), + ) + + final_reads = orchestrator.next_batch() + assert [request.ordinal for request in final_reads] == [8, 9, 10] + for request in final_reads: + payload = ( + _device_info_response(session_id=request.session_id) + if request.message_type == "DeviceInfoRequest" + else _generic_response(request.session_id) + ) + orchestrator.accept_response(request.response_topic, payload) + + snapshot = orchestrator.snapshot() + assert snapshot.bootstrap_complete + assert snapshot.live_binding_observed + assert snapshot.pending_response_topics == () + assert snapshot.executable is False + assert snapshot.vendor_writes_enabled is False + with pytest.raises(ApplicationBootstrapError, match="already complete"): + orchestrator.next_batch() + + +def test_orchestrator_rejects_unexpected_response_without_advancing() -> None: + orchestrator = ShadowApplicationBootstrapOrchestrator( + _authority(), + epoch_seconds=1_752_680_000, + timezone_name="Europe/Moscow", + ) + orchestrator.next_batch() + + with pytest.raises(ApplicationBootstrapError, match="not required"): + orchestrator.accept_response( + "lixel/application/response/get_rtk_advance", + _generic_response(":GetRtkAdvanceRequest"), + ) + + assert orchestrator.snapshot().current_batch == 1 + assert orchestrator.snapshot().batch_issued + + +def test_application_authority_requires_exact_captured_profile_length() -> None: + with pytest.raises(ApplicationBootstrapError, match="36-byte"): + ApplicationControlAuthority(openapi_key="too-short") diff --git a/tests/test_xgrids_application_publish.py b/tests/test_xgrids_application_publish.py new file mode 100644 index 0000000..4eaacc8 --- /dev/null +++ b/tests/test_xgrids_application_publish.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import pytest + +from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( + ApplicationControlAuthority, + LiveDeviceControlBinding, + build_shadow_bootstrap, +) +from k1link.device_plugins.xgrids_k1.protocol.application_publish import ( + OneShotPublishEnvelope, + VendorWritesDisabledError, + WriteDisabledOneShotPublisher, +) + +PRIVATE_AUTHORITY = "11111111-2222-3333-4444-555555555555" + + +class SpySink: + def __init__(self) -> None: + self.calls = 0 + + def publish( + self, + *, + topic: str, + payload: bytes, + qos: int, + retain: bool, + ) -> None: + del topic, payload, qos, retain + self.calls += 1 + + +def _envelope() -> OneShotPublishEnvelope: + authority = ApplicationControlAuthority(openapi_key=PRIVATE_AUTHORITY) + binding = LiveDeviceControlBinding( + vendor_device_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + device_serial="K1SERIAL01", + software_version="V3.0.2-build.1", + system_version="V3.0.2", + device_model="LixelKity K1", + device_type="K1", + is_activated=True, + ) + request = build_shadow_bootstrap( + authority, + binding, + epoch_seconds=1_752_680_000, + timezone_name="Europe/Moscow", + ).requests[0] + return OneShotPublishEnvelope.from_bootstrap_request(request) + + +def test_write_disabled_publisher_never_touches_injected_transport() -> None: + sink = SpySink() + publisher = WriteDisabledOneShotPublisher(sink) + envelope = _envelope() + + for _ in range(2): + with pytest.raises(VendorWritesDisabledError, match="cannot publish"): + publisher.publish_once(envelope) + + assert sink.calls == 0 + assert publisher.snapshot().as_dict() == { + "mode": "write-disabled", + "denied_attempts": 2, + "transport_calls": 0, + "vendor_writes_enabled": False, + "publisher_armed": False, + "automatic_retry": False, + } + + +def test_publish_envelope_redacts_payload_and_rejects_retry_semantics() -> None: + envelope = _envelope() + + assert PRIVATE_AUTHORITY not in repr(envelope) + assert PRIVATE_AUTHORITY not in str(envelope.as_dict()) + with pytest.raises(ValueError, match="one-shot contract"): + OneShotPublishEnvelope( + topic=envelope.topic, + payload=envelope.payload, + payload_sha256=envelope.payload_sha256, + payload_bytes=envelope.payload_bytes, + qos=1, + retain=False, + ) diff --git a/tests/test_xgrids_modeling_safety.py b/tests/test_xgrids_modeling_safety.py index ac2dd66..0fa36fc 100644 --- a/tests/test_xgrids_modeling_safety.py +++ b/tests/test_xgrids_modeling_safety.py @@ -65,7 +65,7 @@ def _status_message( def _authority() -> ApplicationControlAuthority: - return ApplicationControlAuthority(openapi_key="application-private-key") + return ApplicationControlAuthority(openapi_key="11111111-2222-3333-4444-555555555555") def _binding(**changes: object) -> LiveDeviceControlBinding: @@ -120,8 +120,8 @@ def test_start_shadow_plan_is_exact_bounded_and_never_executable() -> None: assert plan.executable is False assert plan.blockers == ("vendor-writes-disabled", "publisher-not-installed") assert plan.retry_policy == "never-automatic" - assert "application-private-key" not in repr(plan) - assert "application-private-key" not in str(plan.as_dict()) + assert "11111111-2222-3333-4444-555555555555" not in repr(plan) + assert "11111111-2222-3333-4444-555555555555" not in str(plan.as_dict()) def test_stop_shadow_plan_requires_same_live_device_and_active_project() -> None: