From d7749208a770e9fe97403c74814104bf854fec3c Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 18 Jul 2026 13:54:25 +0300 Subject: [PATCH] feat(k1): recover exact application bootstrap --- README.md | 13 +- docs/01_IMPLEMENTATION_PLAN.md | 26 +- docs/05_K1_MQTT_STREAM_PROFILE.md | 33 +- docs/06_K1_LIVE_VIEWER.md | 19 +- docs/07_MISSION_CORE_MONOREPO.md | 22 +- docs/09_OBSERVATION_SESSIONS.md | 2 +- ...n-observation-runtime-and-k1-extraction.md | 6 +- .../0012-device-bound-k1-command-authority.md | 158 +++--- plugins/xgrids-k1/profile_loader.py | 2 +- plugins/xgrids-k1/profiles/README.md | 7 +- .../profiles/fw-3.0.2/direct-lan.v1.json | 8 +- .../protocol/application_bootstrap.py | 502 ++++++++++++++++++ .../xgrids_k1/protocol/modeling_safety.py | 97 ++-- tests/test_xgrids_application_bootstrap.py | 167 ++++++ tests/test_xgrids_modeling_safety.py | 59 +- 15 files changed, 906 insertions(+), 215 deletions(-) create mode 100644 src/k1link/device_plugins/xgrids_k1/protocol/application_bootstrap.py create mode 100644 tests/test_xgrids_application_bootstrap.py diff --git a/README.md b/README.md index 1d3d891..dc0672d 100644 --- a/README.md +++ b/README.md @@ -139,11 +139,14 @@ stopped by the verified double-click. The exact recovered `ModelingRequest` start/stop encoder, response correlator and device-status state machine are implemented as inert protocol components. -The command header derives `${device_id}:ModelingRequest` from an explicit ASCII -device ID and requires an explicit ASCII OpenAPI key, but the key's legitimate -provenance/provisioning and durable post-stop save evidence remain unresolved. -There is no MQTT publisher in this path, `vendor_writes_enabled` remains false, -and no modeling command is sent. +The plugin also contains the exact ten-request pre-START bootstrap recovered +from retained LixelGO traffic. Its first `DeviceInfoRequest` has no device ID; +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. 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 232e439..bb6c795 100644 --- a/docs/01_IMPLEMENTATION_PLAN.md +++ b/docs/01_IMPLEMENTATION_PLAN.md @@ -21,23 +21,25 @@ 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 (shadow gate implemented) — the exact captured codec is byte-matched, live status binds vendor identity plus serial, and START/STOP can be planned without publish authority; device-bound OpenAPI enrollment, one-shot publisher, physical save acceptance and recovery policy remain closed gates | +| 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 | | 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 now-verified network path. Owner-operated LixelGO traffic verifies the MQTT start/stop mapping and RTSP camera transport. The exact start/stop protobuf encoder, response correlator and device-status state machine now exist without a -publisher. Header construction is known: an explicit ASCII device ID and ASCII -OpenAPI key are required, while the session ID is derived exactly as -`${device_id}:ModelingRequest`. MQTT control publishing remains deliberately -disabled. A live-only shadow safety layer now observes the device-reported -vendor identity and serial, rejects identity drift or malformed status, and can -form a non-executable START only from unbound `READY` or STOP only from a bound -`SCANNING` state. Its output contains a digest and wire metadata, never the -device identity, serial, OpenAPI value or payload. Legitimate per-device OpenAPI -provisioning, an authorized one-shot publisher and a durable post-stop save gate -are not proven. The physical button remains the known-safe fallback. +publisher. Retained PCAP plus client static analysis prove that OpenAPI is one +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. 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; @@ -256,7 +258,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; -- legitimate K1 OpenAPI credential provisioning, authorized command transport +- private application-level OpenAPI authority loading, 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 755fdcc..9e7e1e6 100644 --- a/docs/05_K1_MQTT_STREAM_PROFILE.md +++ b/docs/05_K1_MQTT_STREAM_PROFILE.md @@ -166,14 +166,27 @@ separated four values which must never be substituted for one another: belongs in a vendor request; - the vendor device ID comes from live K1 status and occupies the request header; - the K1 serial is a separate live device-binding value; -- the OpenAPI value is a private, device-bound command credential and is not - present in the status report. +- the OpenAPI value is private application-level material embedded by the + observed LixelGO build; it is shared across all retained request types and is + not derived from the current scanner while the application value is present. -Consequently, support for another K1 cannot replay this scanner's captured -payload or OpenAPI value. It requires a separate owner-authorized enrollment -whose vendor device ID and serial are attested against live status under the -same exact firmware/topology profile. Any mismatch, identity drift, malformed -status, replay-only evidence or unexpected lifecycle state fails closed. +Consequently, support for another K1 never replays captured payload bytes and +does not require a manually maintained scanner profile. BLE selection and its +provisioning response bind the transport to that unit; an initial +`DeviceInfoRequest` without device ID returns the live vendor ID, serial, model, +activation and version facts. Those values bind later requests and must agree +with the live status stream. Any mismatch, identity drift, malformed response, +replay-only evidence or unexpected lifecycle state fails closed. + +The clean LixelGO cycle contains exactly ten requests before START, all QoS 2 +and retain false: `DeviceInfo`, `ModelingStatus`, `GetRtkAdvance`, a bound +`DeviceConfig` time/timezone sync, then `DeviceInfo`, `GetRtkAdvance`, +`GetNtripProfile`, `GetCloudServerConfig`, `GetRtkAdvance`, `DeviceInfo`. The +special time-sync session is exactly +`${device_id}:DeviceConfigRequest:Publish_Proto_DeviceConfig_SetTime`. The +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 repository now contains an inert bounded encoder/response parser and a fail-closed device-status state machine for this exact profile. A live-only @@ -182,9 +195,9 @@ 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: secure per-device OpenAPI enrollment -is unresolved, 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 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 `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 738af23..dd5a039 100644 --- a/docs/06_K1_LIVE_VIEWER.md +++ b/docs/06_K1_LIVE_VIEWER.md @@ -143,14 +143,13 @@ per-message metadata and a hash summary. Repository-level the current writer. The connector subscribes to the fixed report-topic allowlist and does not publish an application request or modeling command. -The recovered command substrate is intentionally inert. It can encode exact -start/stop request bytes, correlate response identity/action/result and classify -observed device states without importing MQTT or publishing anything. The header -requires explicit ASCII `device_id` and OpenAPI key and derives the session ID -exactly as `${device_id}:ModelingRequest`. Legitimate OpenAPI key -provenance/provisioning and durable save completion after stop are unresolved, -so `vendor_writes_enabled` is false and the physical-button workflow remains -canonical. +The recovered command substrate is intentionally inert. It byte-matches the ten +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 +`vendor_writes_enabled` is false and the physical-button workflow is canonical. ## Automatic Rerun source and lifecycle @@ -332,8 +331,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 a separately reviewed state-changing profile with legitimate - OpenAPI key provisioning and a proven durable-save completion gate. + publisher needs reviewed private application-authority loading, 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 83d20aa..93eac5e 100644 --- a/docs/07_MISSION_CORE_MONOREPO.md +++ b/docs/07_MISSION_CORE_MONOREPO.md @@ -142,16 +142,14 @@ transport mutation. Application-command publishing remains disabled; the separately reviewed BLE Wi-Fi provisioning write retains its own explicit operator gate. -An exact but inert K1 modeling-control substrate now lives inside the vendor -plugin. It encodes the recovered start/stop protobuf shapes, strictly correlates -response identity/action/numeric result and maps bounded device-status values -into an observation-only state machine. Header construction is no longer -guessed: callers provide explicit ASCII device ID and ASCII OpenAPI key, and the -session ID is derived exactly as `${device_id}:ModelingRequest`. No MQTT import -or publisher exists in that substrate. Legitimate OpenAPI key -provenance/provisioning and stable-artifact proof after the stop lifecycle are -still unresolved, so `vendor_writes_enabled=false` and every state-machine -snapshot remains false for durable save completion. +An exact but inert K1 application-control substrate now lives inside the vendor +plugin. It byte-matches the retained ten-request pre-START dialogue plus +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 +unresolved, so `vendor_writes_enabled=false`. ## Semantic lifecycle @@ -223,8 +221,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 legitimate OpenAPI - key provisioning and durable-save confirmation close its separate safety + plane. Keep the modeling-command publisher disabled until private application + authority loading and durable-save confirmation close its separate 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 f6c4f5c..747dcf0 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: legitimate OpenAPI key provisioning and durable save completion +publishing: private application-authority loading 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 8d6ff21..292abbe 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 @@ -112,9 +112,9 @@ plugin-owned. 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 modeling-control codec has -no publisher, and automatic K1 writes remain disabled pending legitimate OpenAPI -credential provisioning and durable post-stop save evidence. +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. 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 92b8478..ece15cc 100644 --- a/docs/adr/0012-device-bound-k1-command-authority.md +++ b/docs/adr/0012-device-bound-k1-command-authority.md @@ -1,4 +1,4 @@ -# ADR 0012: device-bound K1 command authority +# ADR 0012: live-bound K1 control with application-level authority - Status: accepted - Date: 2026-07-18 @@ -6,89 +6,113 @@ ## Context -Owner-controlled LixelGO traffic proves the K1 modeling START/STOP protobuf -shape, MQTT topic, QoS and correlated success response for one K1 running the -exact `xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1` profile. A bounded offline -audit also proves that Mission Core's encoder reproduces both retained requests -byte-for-byte. This is sufficient to describe the current device dialogue, but -not to replay a captured request or authorize writes to any K1. +Owner-controlled LixelGO traffic proves the K1 preparation and modeling +dialogue for one activated K1 running the exact +`xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1` profile. Offline regression proves +that Mission Core reproduces all ten requests before START, plus START and STOP, +byte-for-byte. This describes the current device dialogue but does not authorize +writes to any K1. -The protocol exposes several identities with different authority. Mission -Core's provisional device UUID is local inventory identity. The vendor device -ID is reported by the live K1 and occupies the request header. The K1 serial is -a separate device-binding value. The OpenAPI value is private command material -which is absent from live status. LixelGO derives the command session relation -from the vendor device ID and request type; it is not a reusable caller session. +The protocol exposes identities with different ownership. Mission Core's +inventory UUID and the macOS CoreBluetooth UUID are local transport identities. +The vendor device ID and serial identify the live scanner. The OpenAPI value is +private application material: retained traffic uses one value across every +request type, and decompiled LixelGO uses its embedded application value before +the serial-derived fallback. It is therefore not a manual per-scanner profile. -Native project durability is also not equivalent to command acceptance. STOP -success, stream quiescence, return to READY/steady green and a reusable project -on the scanner are separate observations. +The selected BLE peripheral returns the LAN address of that same unit during +the reviewed Wi-Fi provisioning exchange. On MQTT, LixelGO first sends +`DeviceInfoRequest` without a device ID. `DeviceInfoResponse` supplies vendor ID, +serial, model, activation and version facts; subsequent headers bind to that +identity. Native project durability remains separate from command acceptance. ## Decision -K1 application control is exact-profile, device-bound and fail-closed. +K1 application control is exact-profile, live-bound and fail-closed. -1. Mission Core never sends its local inventory UUID, BLE identifier or a K1 - serial in place of the vendor request identity. -2. Each K1 requires an owner-authorized enrollment containing its vendor device - ID, serial and private OpenAPI value. Enrollment secrets are not committed, - placed in Ops, returned by APIs or written to logs. -3. Before a command can be considered, live MQTT status must attest the same - vendor device ID and serial under the exact firmware/topology profile. - Replay evidence cannot establish current command authority. -4. Identity drift, missing identity, malformed status, profile mismatch or an - unexpected lifecycle state blocks command planning. -5. START retains the observed LixelGO parameters: record-and-calculate mode 2, - LCC scan mode 1, handheld mount mode 0 and the operator's validated project - name. STOP contains only the same bound header and action. -6. MQTT command semantics remain QoS 2, retain false. Automatic retry is - forbidden: an unknown outcome must be reconciled from live status and - physical evidence before another command. -7. No firmware, activation, account, update or vendor-cloud endpoint is part of - the control path. Local direct-LAN MQTT is the only reviewed transport. -8. STOP completion has four gates: correlated command result, local stream - quiescence and evidence sealing, READY/steady-green device state, then native - project verification. Earlier gates must not claim the later ones. +1. Mission Core never substitutes its inventory UUID, BLE UUID or serial for + the vendor request identity. +2. One private application-level OpenAPI authority is loaded outside Git, Ops, + browser state, APIs and logs. It is not copied into scanner profiles. +3. The BLE-selected transport is followed by an unbound DeviceInfo exchange. + Its live vendor ID, serial, activation and FW 3.0.2 facts form the transient + device binding; a saved profile is optional metadata, not protocol authority. +4. The ten retained pre-START requests keep their exact order and response + boundaries. The only mutation is the observed time/timezone sync; the other + nine requests are reads. No request is batched, skipped or automatically + retried. +5. Live DeviceInfo binding must agree with the live DeviceStatus stream before + START/STOP planning. Identity drift, malformed data, inactive equipment, + profile mismatch or an unexpected lifecycle state fails closed. +6. START retains record-and-calculate mode 2, LCC mode 1, handheld mount 0 and + the validated project name. STOP contains only bound header and action. +7. MQTT application semantics remain QoS 2, retain false. An unknown outcome is + reconciled from correlated response, status and physical evidence before any + new operator-authorized attempt. +8. No firmware, activation, account, update or vendor-cloud mutation belongs to + this path. `GetCloudServerConfig` is a local K1 read. +9. STOP completion has separate gates: correlated result, local stream + quiescence/evidence sealing, READY plus steady green, then independent native + project verification. + +## Recovered pre-START sequence + +The clean cycle contains: + +1. unbound `DeviceInfoRequest`; +2. unbound `ModelingStatusRequest`; +3. unbound `GetRtkAdvanceRequest`; +4. bound `DeviceConfigRequest` time/timezone sync; +5. bound `DeviceInfoRequest`; +6. bound `GetRtkAdvanceRequest`; +7. bound `GetNtripProfileRequest`; +8. bound `GetCloudServerConfigRequest`; +9. bound `GetRtkAdvanceRequest`; +10. bound `DeviceInfoRequest`; +11. bound `ModelingRequest` START. + +Normal sessions are `${device_id_or_empty}:${MessageType}`. Time sync uses the +captured special relation +`${device_id}:DeviceConfigRequest:Publish_Proto_DeviceConfig_SetTime`. ## Current implementation boundary -`LiveModelingControlSafety` observes only live device-status messages. It binds -vendor identity and serial, tracks lifecycle state and creates non-executable -shadow START/STOP plans. Public state and plan output contain only booleans, -counts, action, topic, QoS, retain flag, payload length and digest. The encoded -payload and enrolled values remain private in memory. +`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. -The shadow plan is blocked by both `vendor-writes-disabled` and -`publisher-not-installed`. There is no MQTT publisher, enrollment endpoint or -automatic retry path. The physical K1 control remains the production fallback. +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. ## Promotion gate -A future publisher requires a separate review and an operator-present physical -acceptance on the enrolled K1: +A future publisher requires separate review and an operator-present physical +acceptance: -1. confirm exact firmware/profile, steady-green state, battery and storage; -2. close LixelGO and arm raw evidence plus the intended camera receiver; -3. attest live READY identity against the device enrollment; -4. review the shadow command metadata; -5. send one START, without retry, and correlate its exact response; +1. load the private application authority through a reviewed local secret + mechanism; +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; +5. send one START without retry and correlate its response; 6. observe calibration and first point/pose/camera data; -7. send one STOP, without retry, and correlate its exact response; +7. send one STOP without retry and correlate its response; 8. seal local evidence while accepting the bounded stream tail; -9. wait for READY and steady green before power-off or another scan; -10. verify the native project independently through the vendor-supported - workflow. +9. wait for READY and steady green; +10. verify the native project through the vendor-supported workflow. -Any unknown response, identity change, activation state, fault, low battery or -unexpected lifecycle transition pauses the test. It does not trigger a guessed -recovery command. +Any unknown response, identity change, fault, low battery/storage or unexpected +transition pauses the test. It never triggers a guessed recovery command. ## Consequences -Adding another K1 means creating another private enrollment and proving its -exact compatibility profile; it never means reusing captured bytes from the -first scanner. The design preserves native record-and-calculate behavior while -keeping command authority inside the XGRIDS plugin and out of generic Mission -Core. It intentionally postpones convenience automation until identity, -credential provenance, one-shot transport behavior and durable save semantics -have all passed physical acceptance. +Adding another identical K1 means selecting it over BLE and deriving a fresh +live DeviceInfo binding, not creating or copying a command profile. A second +physical K1 remains a useful portability acceptance, but another packet capture +is not required before implementing the reviewed current profile. The design +preserves native record-and-calculate behavior while keeping vendor control in +the XGRIDS plugin and out of generic Mission Core. diff --git a/plugins/xgrids-k1/profile_loader.py b/plugins/xgrids-k1/profile_loader.py index b1cfc01..a209ea2 100644 --- a/plugins/xgrids-k1/profile_loader.py +++ b/plugins/xgrids-k1/profile_loader.py @@ -425,7 +425,7 @@ def _validate_acquisition_control(profile: dict[str, Any]) -> None: if mapping.get("header_contract") != { "device_id": "explicit-observed-identity", "session_id": "{device_id}:ModelingRequest", - "openapi_key": "explicit-observed-value-with-unresolved-provenance", + "openapi_key": "private-application-level-runtime-authority", }: raise CompatibilityProfileError( f"{action_id} header contract differs from retained evidence" diff --git a/plugins/xgrids-k1/profiles/README.md b/plugins/xgrids-k1/profiles/README.md index b0de120..4315d04 100644 --- a/plugins/xgrids-k1/profiles/README.md +++ b/plugins/xgrids-k1/profiles/README.md @@ -37,9 +37,10 @@ is observed, but its media decoder/replay flags remain independent. 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. The bounded codec accepts an OpenAPI value only as an -explicit secret; its provenance and secure provisioning are unresolved, as are -durable save completion, timeout and rollback behavior. Acquisition therefore +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 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 3d9e6dc..d8aaa52 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 @@ -348,7 +348,7 @@ "header_contract": { "device_id": "explicit-observed-identity", "session_id": "{device_id}:ModelingRequest", - "openapi_key": "explicit-observed-value-with-unresolved-provenance" + "openapi_key": "private-application-level-runtime-authority" }, "request_fields": { "project_name": "required-operator-value", @@ -359,7 +359,7 @@ }, "success_result_code": 302252033, "required_unresolved_context": [ - "OpenAPI credential provenance and secure provisioning", + "secure private application-authority loading", "authorization policy for any setting outside the retained request", "timeout, rejection and rollback contract" ], @@ -401,12 +401,12 @@ "header_contract": { "device_id": "explicit-observed-identity", "session_id": "{device_id}:ModelingRequest", - "openapi_key": "explicit-observed-value-with-unresolved-provenance" + "openapi_key": "private-application-level-runtime-authority" }, "request_fields": {}, "success_result_code": 302252033, "required_unresolved_context": [ - "OpenAPI credential provenance and secure provisioning", + "secure private application-authority loading", "save-completion and final-standby state mapping", "timeout and rollback contract" ], diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/application_bootstrap.py b/src/k1link/device_plugins/xgrids_k1/protocol/application_bootstrap.py new file mode 100644 index 0000000..4c70784 --- /dev/null +++ b/src/k1link/device_plugins/xgrids_k1/protocol/application_bootstrap.py @@ -0,0 +1,502 @@ +from __future__ import annotations + +import hashlib +import hmac +import re +from dataclasses import dataclass, field +from typing import Literal + +from k1link.device_plugins.xgrids_k1.protocol.modeling_control import OPENAPI_SUCCESS +from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import ( + ProtobufWireError, + ProtoField, + iter_fields, +) + +MAX_APPLICATION_PAYLOAD_BYTES = 64 * 1024 +MAX_HEADER_BYTES = 4 * 1024 +MAX_TEXT_BYTES = 4 * 1024 +APPLICATION_AUTHORITY_SOURCE = "owner-captured-lixelgo-application" +COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1" +DEVICE_CONFIG_TIME_CONTEXT = "Publish_Proto_DeviceConfig_SetTime" +SHADOW_BLOCKERS = ("vendor-writes-disabled", "publisher-not-installed") + +DEVICE_INFO_REQUEST_TOPIC = "lixel/application/request/device_info" +DEVICE_INFO_RESPONSE_TOPIC = "lixel/application/response/device_info" +MODELING_STATUS_REQUEST_TOPIC = "lixel/application/request/modeling_status" +MODELING_STATUS_RESPONSE_TOPIC = "lixel/application/response/modeling_status" +GET_RTK_ADVANCE_REQUEST_TOPIC = "lixel/application/request/get_rtk_advance" +GET_RTK_ADVANCE_RESPONSE_TOPIC = "lixel/application/response/get_rtk_advance" +DEVICE_CONFIG_REQUEST_TOPIC = "lixel/application/request/device_config" +DEVICE_CONFIG_RESPONSE_TOPIC = "lixel/application/response/device_config" +GET_NTRIP_PROFILE_REQUEST_TOPIC = "lixel/application/request/get_ntrip_profile" +GET_NTRIP_PROFILE_RESPONSE_TOPIC = "lixel/application/response/get_ntrip_profile" +GET_CLOUD_CONFIG_REQUEST_TOPIC = "lixel/application/request/get_cloud_server_config" +GET_CLOUD_CONFIG_RESPONSE_TOPIC = "lixel/application/response/get_cloud_server_config" + + +class ApplicationBootstrapError(ValueError): + """The recovered K1 application bootstrap contract was violated.""" + + +@dataclass(frozen=True, slots=True) +class ApplicationControlAuthority: + """Private authority belonging to the reviewed LixelGO application profile. + + The retained application and wire evidence show one OpenAPI value shared by + all request types. It is not a scanner serial or a per-device enrollment. + """ + + openapi_key: str = field(repr=False) + source: Literal["owner-captured-lixelgo-application"] = "owner-captured-lixelgo-application" + compatibility_profile_id: Literal["xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"] = ( + "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1" + ) + + def __post_init__(self) -> None: + if ( + self.source != APPLICATION_AUTHORITY_SOURCE + or self.compatibility_profile_id != COMPATIBILITY_PROFILE_ID + ): + raise ApplicationBootstrapError( + "application authority does not match the reviewed K1 profile" + ) + _validate_ascii_identity(self.openapi_key, "openapi_key") + + +@dataclass(frozen=True, slots=True) +class LiveDeviceControlBinding: + """Identity and compatibility facts learned from the selected live K1.""" + + vendor_device_id: str = field(repr=False) + device_serial: str = field(repr=False) + software_version: str = field(repr=False) + system_version: str = field(repr=False) + device_model: str = field(repr=False) + device_type: str = field(repr=False) + is_activated: bool + + def __post_init__(self) -> None: + _validate_ascii_identity(self.vendor_device_id, "vendor_device_id") + _validate_ascii_identity(self.device_serial, "device_serial") + for name, value in ( + ("software_version", self.software_version), + ("system_version", self.system_version), + ("device_model", self.device_model), + ("device_type", self.device_type), + ): + _validate_text(value, name) + + @property + def matches_reviewed_firmware(self) -> bool: + return _contains_version(self.software_version, "3.0.2") and _contains_version( + self.system_version, "3.0.2" + ) + + @property + def ready_for_reviewed_profile(self) -> bool: + return self.is_activated and self.matches_reviewed_firmware + + +@dataclass(frozen=True, slots=True) +class ApplicationRequestHeader: + message_type: str + authority: ApplicationControlAuthority = field(repr=False) + binding: LiveDeviceControlBinding | None = field(default=None, repr=False) + context: str | None = None + + def __post_init__(self) -> None: + _validate_ascii_identity(self.message_type, "message_type") + if self.context is not None: + _validate_ascii_identity(self.context, "context") + if len(self.session_id.encode("ascii")) > MAX_TEXT_BYTES: + raise ApplicationBootstrapError("derived session_id exceeds configured limit") + + @property + def session_id(self) -> str: + device_id = self.binding.vendor_device_id if self.binding is not None else "" + session = f"{device_id}:{self.message_type}" + if self.context is not None: + session = f"{session}:{self.context}" + return session + + +@dataclass(frozen=True, slots=True) +class EncodedApplicationRequest: + ordinal: int + phase: Literal["identity-discovery", "bound-preparation"] + message_type: str + topic: str + response_topic: str + payload: bytes = field(repr=False) + payload_sha256: str + payload_bytes: int + mutates_device: bool + requires_live_binding: bool + qos: int = 2 + retain: bool = False + + def as_dict(self) -> dict[str, object]: + return { + "ordinal": self.ordinal, + "phase": self.phase, + "message_type": self.message_type, + "topic": self.topic, + "response_topic": self.response_topic, + "payload_sha256": self.payload_sha256, + "payload_bytes": self.payload_bytes, + "mutates_device": self.mutates_device, + "requires_live_binding": self.requires_live_binding, + "qos": self.qos, + "retain": self.retain, + } + + +@dataclass(frozen=True, slots=True) +class ShadowApplicationBootstrap: + """Exact observed pre-START request order, with no execution authority.""" + + requests: tuple[EncodedApplicationRequest, ...] = field(repr=False) + executable: bool = False + blockers: tuple[str, ...] = SHADOW_BLOCKERS + retry_policy: Literal["never-automatic"] = "never-automatic" + + def as_dict(self) -> dict[str, object]: + return { + "mode": "shadow-only", + "request_count": len(self.requests), + "mutation_count": sum(request.mutates_device for request in self.requests), + "requests": [request.as_dict() for request in self.requests], + "executable": self.executable, + "blockers": list(self.blockers), + "retry_policy": self.retry_policy, + } + + +@dataclass(frozen=True, slots=True) +class DeviceInfoResponse: + binding: LiveDeviceControlBinding = field(repr=False) + session_id: str = field(repr=False) + openapi_key: str = field(repr=False) + result_code: int + + +def build_shadow_bootstrap( + authority: ApplicationControlAuthority, + binding: LiveDeviceControlBinding, + *, + epoch_seconds: int, + timezone_name: str, +) -> ShadowApplicationBootstrap: + """Build the ten pre-START requests observed in the clean LixelGO cycle. + + The first three requests intentionally omit the vendor device ID. The + remaining requests are bound to identity returned by ``DeviceInfoResponse``. + The only preparatory mutation is the observed clock/timezone sync. + """ + + if not binding.ready_for_reviewed_profile: + raise ApplicationBootstrapError( + "live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile" + ) + if not isinstance(epoch_seconds, int) or isinstance(epoch_seconds, bool): + raise ApplicationBootstrapError("epoch_seconds must be an integer") + if epoch_seconds < 0 or epoch_seconds > 0x7FFF_FFFF_FFFF_FFFF: + raise ApplicationBootstrapError("epoch_seconds is outside recovered int64 range") + _validate_text(timezone_name, "timezone_name") + + specs = ( + ( + "identity-discovery", + "DeviceInfoRequest", + DEVICE_INFO_REQUEST_TOPIC, + DEVICE_INFO_RESPONSE_TOPIC, + None, + False, + ), + ( + "identity-discovery", + "ModelingStatusRequest", + MODELING_STATUS_REQUEST_TOPIC, + MODELING_STATUS_RESPONSE_TOPIC, + None, + False, + ), + ( + "identity-discovery", + "GetRtkAdvanceRequest", + GET_RTK_ADVANCE_REQUEST_TOPIC, + GET_RTK_ADVANCE_RESPONSE_TOPIC, + None, + False, + ), + ( + "bound-preparation", + "DeviceConfigRequest", + DEVICE_CONFIG_REQUEST_TOPIC, + DEVICE_CONFIG_RESPONSE_TOPIC, + DEVICE_CONFIG_TIME_CONTEXT, + True, + ), + ( + "bound-preparation", + "DeviceInfoRequest", + DEVICE_INFO_REQUEST_TOPIC, + DEVICE_INFO_RESPONSE_TOPIC, + None, + False, + ), + ( + "bound-preparation", + "GetRtkAdvanceRequest", + GET_RTK_ADVANCE_REQUEST_TOPIC, + GET_RTK_ADVANCE_RESPONSE_TOPIC, + None, + False, + ), + ( + "bound-preparation", + "GetNtripProfileRequest", + GET_NTRIP_PROFILE_REQUEST_TOPIC, + GET_NTRIP_PROFILE_RESPONSE_TOPIC, + None, + False, + ), + ( + "bound-preparation", + "GetCloudServerConfigRequest", + GET_CLOUD_CONFIG_REQUEST_TOPIC, + GET_CLOUD_CONFIG_RESPONSE_TOPIC, + None, + False, + ), + ( + "bound-preparation", + "GetRtkAdvanceRequest", + GET_RTK_ADVANCE_REQUEST_TOPIC, + GET_RTK_ADVANCE_RESPONSE_TOPIC, + None, + False, + ), + ( + "bound-preparation", + "DeviceInfoRequest", + DEVICE_INFO_REQUEST_TOPIC, + DEVICE_INFO_RESPONSE_TOPIC, + None, + False, + ), + ) + requests: list[EncodedApplicationRequest] = [] + for ordinal, (phase, message_type, topic, response_topic, context, mutates) in enumerate( + specs, start=1 + ): + request_binding = None if ordinal <= 3 else binding + header = ApplicationRequestHeader( + message_type=message_type, + authority=authority, + binding=request_binding, + context=context, + ) + body = b"" + if message_type == "DeviceConfigRequest": + time_config = _varint_field(1, epoch_seconds) + _text_field(2, timezone_name) + body = _bytes_field(4, time_config) + payload = _bytes_field(1, _encode_header(header)) + body + _check_payload(payload, "application request") + requests.append( + EncodedApplicationRequest( + ordinal=ordinal, + phase=phase, # type: ignore[arg-type] + message_type=message_type, + topic=topic, + response_topic=response_topic, + payload=payload, + payload_sha256=hashlib.sha256(payload).hexdigest(), + payload_bytes=len(payload), + mutates_device=mutates, + requires_live_binding=ordinal > 3, + ) + ) + return ShadowApplicationBootstrap(tuple(requests)) + + +def decode_and_bind_device_info_response( + payload: bytes, + authority: ApplicationControlAuthority, +) -> DeviceInfoResponse: + """Correlate the bootstrap response and derive a live per-device binding.""" + + _check_payload(payload, "DeviceInfoResponse") + top = _unique_fields(payload, "DeviceInfoResponse", max_fields=64) + header = _unique_fields(_required_bytes(top, 1, "response.header"), "header", max_fields=32) + 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": + raise ApplicationBootstrapError("DeviceInfoResponse session correlation failed") + if not hmac.compare_digest(openapi_key, authority.openapi_key): + raise ApplicationBootstrapError("DeviceInfoResponse application authority mismatch") + + error = _unique_fields(_required_bytes(top, 15, "response.error"), "error", max_fields=8) + result_code = _required_uint(error, 1, "response.error.code") + if result_code != OPENAPI_SUCCESS: + raise ApplicationBootstrapError( + f"DeviceInfoResponse rejected with result code {result_code}" + ) + + device_info = _unique_fields( + _required_bytes(top, 2, "response.device_info"), "device_info", max_fields=32 + ) + base_info = _unique_fields( + _required_bytes(device_info, 2, "device_info.base_info"), + "base_info", + max_fields=32, + allow_repeated={11}, + ) + working_status = _unique_fields( + _required_bytes(device_info, 7, "device_info.working_status"), + "working_status", + max_fields=16, + ) + binding = LiveDeviceControlBinding( + vendor_device_id=device_id, + device_serial=_required_text(base_info, 7, "base_info.device_sn"), + software_version=_required_text(base_info, 2, "base_info.software_version"), + system_version=_required_text(base_info, 3, "base_info.system_version"), + device_model=_required_text(base_info, 6, "base_info.device_model"), + device_type=_required_text(base_info, 8, "base_info.device_type"), + is_activated=bool(_required_bool(working_status, 1, "working_status.is_activated")), + ) + return DeviceInfoResponse(binding, session_id, openapi_key, result_code) + + +def _encode_header(header: ApplicationRequestHeader) -> bytes: + parts: list[bytes] = [] + if header.binding is not None: + parts.append(_text_field(4, header.binding.vendor_device_id)) + parts.extend( + ( + _text_field(5, header.session_id), + _text_field(6, header.authority.openapi_key), + ) + ) + payload = b"".join(parts) + if len(payload) > MAX_HEADER_BYTES: + raise ApplicationBootstrapError("encoded request header exceeds configured limit") + return payload + + +def _contains_version(value: str, expected: str) -> bool: + return re.search(rf"(? None: + if not isinstance(value, str) or not value: + raise ApplicationBootstrapError(f"{name} must be a non-empty string") + try: + encoded = value.encode("ascii") + except UnicodeEncodeError as exc: + raise ApplicationBootstrapError(f"{name} must use printable ASCII") from exc + if len(encoded) > MAX_TEXT_BYTES: + raise ApplicationBootstrapError(f"{name} exceeds configured limit") + if any(byte <= 0x20 or byte > 0x7E for byte in encoded): + raise ApplicationBootstrapError(f"{name} must use printable ASCII without spaces") + + +def _validate_text(value: object, name: str) -> None: + if not isinstance(value, str) or not value: + raise ApplicationBootstrapError(f"{name} must be a non-empty string") + encoded = value.encode("utf-8") + if len(encoded) > MAX_TEXT_BYTES: + raise ApplicationBootstrapError(f"{name} exceeds configured limit") + if any(ord(character) < 0x20 or ord(character) == 0x7F for character in value): + raise ApplicationBootstrapError(f"{name} contains a control character") + + +def _check_payload(payload: bytes, name: str) -> None: + if len(payload) > MAX_APPLICATION_PAYLOAD_BYTES: + raise ApplicationBootstrapError(f"{name} exceeds configured limit") + + +def _unique_fields( + payload: bytes, + name: str, + *, + max_fields: int, + allow_repeated: set[int] | None = None, +) -> dict[int, ProtoField]: + result: dict[int, ProtoField] = {} + repeated = allow_repeated or set() + try: + for item in iter_fields(payload, max_fields=max_fields): + if item.number in result and item.number not in repeated: + 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): + raise ApplicationBootstrapError(f"{name} is missing or has the wrong wire type") + return item.value + + +def _required_text(fields: dict[int, ProtoField], number: int, name: str) -> str: + raw = _required_bytes(fields, number, name) + if len(raw) > MAX_TEXT_BYTES: + raise ApplicationBootstrapError(f"{name} exceeds configured limit") + try: + value = raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise ApplicationBootstrapError(f"{name} is not valid UTF-8") from exc + _validate_text(value, name) + return value + + +def _identity_field(fields: dict[int, ProtoField], number: int, name: str) -> str: + value = _required_text(fields, number, name) + _validate_ascii_identity(value, name) + return value + + +def _required_uint(fields: dict[int, ProtoField], number: int, name: str) -> int: + item = fields.get(number) + if item is None or item.wire_type != 0 or not isinstance(item.value, int): + raise ApplicationBootstrapError(f"{name} is missing or has the wrong wire type") + return item.value + + +def _required_bool(fields: dict[int, ProtoField], number: int, name: str) -> int: + value = _required_uint(fields, number, name) + if value not in (0, 1): + raise ApplicationBootstrapError(f"{name} is not a protobuf boolean") + return value + + +def _varint(value: int) -> bytes: + encoded = bytearray() + while value > 0x7F: + encoded.append((value & 0x7F) | 0x80) + value >>= 7 + encoded.append(value) + return bytes(encoded) + + +def _key(number: int, wire_type: int) -> bytes: + return _varint((number << 3) | wire_type) + + +def _varint_field(number: int, value: int) -> bytes: + return _key(number, 0) + _varint(value) + + +def _bytes_field(number: int, value: bytes) -> bytes: + return _key(number, 2) + _varint(len(value)) + value + + +def _text_field(number: int, value: str) -> bytes: + return _bytes_field(number, value.encode("utf-8")) diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/modeling_safety.py b/src/k1link/device_plugins/xgrids_k1/protocol/modeling_safety.py index 5e5e22d..79399ad 100644 --- a/src/k1link/device_plugins/xgrids_k1/protocol/modeling_safety.py +++ b/src/k1link/device_plugins/xgrids_k1/protocol/modeling_safety.py @@ -5,12 +5,15 @@ import threading from dataclasses import dataclass, field from typing import Literal +from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( + ApplicationControlAuthority, + LiveDeviceControlBinding, +) from k1link.device_plugins.xgrids_k1.protocol.modeling_control import ( CommandHeaderIdentity, DeviceStatusReport, EncodedModelingCommand, ModelingAction, - ModelingEncodeError, MountType, RecordMode, ScanMode, @@ -25,59 +28,12 @@ from k1link.viewer.metrics import BridgeMetrics DEVICE_STATUS_TOPIC = "lixel/application/report/device_status" MODELING_REQUEST_TOPIC = "lixel/application/request/modeling" SHADOW_BLOCKERS = ("vendor-writes-disabled", "publisher-not-installed") -CONTROL_ENROLLMENT_SOURCE = "owner-captured-device-bound" -CONTROL_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1" -CONTROL_FIRMWARE_VERSION = "3.0.2" -CONTROL_TOPOLOGY = "direct-lan" class ModelingControlSafetyError(RuntimeError): """A live K1 could not be bound to reviewed device-specific control input.""" -@dataclass(frozen=True, slots=True) -class DeviceBoundControlEnrollment: - """Private, device-specific values learned from owner-controlled evidence. - - Mission Core's provisional device UUID, BLE transport UUID and K1 serial are - not interchangeable with the vendor header identity. An enrollment binds - all three vendor inputs explicitly and keeps them out of repr/API output. - """ - - vendor_device_id: str = field(repr=False) - device_serial: str = field(repr=False) - openapi_key: str = field(repr=False) - source: Literal["owner-captured-device-bound"] = "owner-captured-device-bound" - compatibility_profile_id: Literal["xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"] = ( - "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1" - ) - firmware_version: Literal["3.0.2"] = "3.0.2" - topology: Literal["direct-lan"] = "direct-lan" - - def __post_init__(self) -> None: - if ( - self.source != CONTROL_ENROLLMENT_SOURCE - or self.compatibility_profile_id != CONTROL_COMPATIBILITY_PROFILE_ID - or self.firmware_version != CONTROL_FIRMWARE_VERSION - or self.topology != CONTROL_TOPOLOGY - ): - raise ModelingControlSafetyError( - "control enrollment does not match the exact reviewed K1 profile" - ) - CommandHeaderIdentity( - device_id=self.vendor_device_id, - openapi_key=self.openapi_key, - ) - _validate_device_serial(self.device_serial) - - @property - def command_header(self) -> CommandHeaderIdentity: - return CommandHeaderIdentity( - device_id=self.vendor_device_id, - openapi_key=self.openapi_key, - ) - - @dataclass(frozen=True, slots=True) class ModelingControlSafetySnapshot: status_reports_observed: int @@ -215,13 +171,14 @@ class LiveModelingControlSafety: def plan_start( self, - enrollment: DeviceBoundControlEnrollment, + authority: ApplicationControlAuthority, + binding: LiveDeviceControlBinding, *, project_name: str, ) -> ShadowModelingCommand: - self._require_bound(enrollment, required_state=SessionState.READY, project_bound=False) + self._require_bound(binding, required_state=SessionState.READY, project_bound=False) command = encode_modeling_start( - enrollment.command_header, + _command_header(authority, binding), project_name=project_name, record_mode=RecordMode.RECORD_AND_CALCULATE, scan_mode=ScanMode.LCC, @@ -231,14 +188,17 @@ class LiveModelingControlSafety: def plan_stop( self, - enrollment: DeviceBoundControlEnrollment, + authority: ApplicationControlAuthority, + binding: LiveDeviceControlBinding, ) -> ShadowModelingCommand: - self._require_bound(enrollment, required_state=SessionState.SCANNING, project_bound=True) - return ShadowModelingCommand.from_command(encode_modeling_stop(enrollment.command_header)) + self._require_bound(binding, required_state=SessionState.SCANNING, project_bound=True) + return ShadowModelingCommand.from_command( + encode_modeling_stop(_command_header(authority, binding)) + ) def _require_bound( self, - enrollment: DeviceBoundControlEnrollment, + binding: LiveDeviceControlBinding, *, required_state: SessionState, project_bound: bool, @@ -250,11 +210,15 @@ class LiveModelingControlSafety: if report is None or self._vendor_device_id is None or self._device_serial is None: raise ModelingControlSafetyError("live K1 identity/status evidence is missing") if ( - enrollment.vendor_device_id != self._vendor_device_id - or enrollment.device_serial != self._device_serial + binding.vendor_device_id != self._vendor_device_id + or binding.device_serial != self._device_serial ): raise ModelingControlSafetyError( - "device-bound control enrollment does not match the live K1" + "DeviceInfo binding does not match the live K1 status stream" + ) + if not binding.ready_for_reviewed_profile: + raise ModelingControlSafetyError( + "live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile" ) if report.session_state is not required_state: raise ModelingControlSafetyError( @@ -264,12 +228,11 @@ class LiveModelingControlSafety: raise ModelingControlSafetyError("live K1 project binding is not in the safe state") -def _validate_device_serial(value: object) -> None: - if not isinstance(value, str) or not value: - raise ModelingEncodeError("device_serial must be a non-empty string") - try: - encoded = value.encode("ascii") - except UnicodeEncodeError as exc: - raise ModelingEncodeError("device_serial must use printable ASCII") from exc - if len(encoded) > 256 or any(byte <= 0x20 or byte > 0x7E for byte in encoded): - raise ModelingEncodeError("device_serial must use bounded printable ASCII without spaces") +def _command_header( + authority: ApplicationControlAuthority, + binding: LiveDeviceControlBinding, +) -> CommandHeaderIdentity: + return CommandHeaderIdentity( + device_id=binding.vendor_device_id, + openapi_key=authority.openapi_key, + ) diff --git a/tests/test_xgrids_application_bootstrap.py b/tests/test_xgrids_application_bootstrap.py new file mode 100644 index 0000000..5655523 --- /dev/null +++ b/tests/test_xgrids_application_bootstrap.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import pytest + +from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( + ApplicationBootstrapError, + ApplicationControlAuthority, + LiveDeviceControlBinding, + build_shadow_bootstrap, + decode_and_bind_device_info_response, +) +from k1link.device_plugins.xgrids_k1.protocol.modeling_control import OPENAPI_SUCCESS +from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import iter_fields + +APPLICATION_KEY = "11111111-2222-3333-4444-555555555555" +VENDOR_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +def _varint(value: int) -> bytes: + encoded = bytearray() + while value > 0x7F: + encoded.append((value & 0x7F) | 0x80) + value >>= 7 + encoded.append(value) + return bytes(encoded) + + +def _uint(number: int, value: int) -> bytes: + return _varint(number << 3) + _varint(value) + + +def _bytes(number: int, value: bytes) -> bytes: + return _varint((number << 3) | 2) + _varint(len(value)) + value + + +def _text(number: int, value: str) -> bytes: + return _bytes(number, value.encode()) + + +def _authority() -> ApplicationControlAuthority: + return ApplicationControlAuthority(openapi_key=APPLICATION_KEY) + + +def _binding(**changes: object) -> LiveDeviceControlBinding: + values = { + "vendor_device_id": VENDOR_DEVICE_ID, + "device_serial": "K1SERIAL01", + "software_version": "V3.0.2-20260101-release", + "system_version": "V3.0.2", + "device_model": "LixelKity K1", + "device_type": "K1", + "is_activated": True, + } + values.update(changes) + return LiveDeviceControlBinding(**values) # type: ignore[arg-type] + + +def _device_info_response(*, session_id: str = ":DeviceInfoRequest") -> bytes: + header = _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY) + base_info = b"".join( + ( + _text(1, "2026-01-01T00:00:00"), + _text(2, "V3.0.2-20260101-release"), + _text(3, "V3.0.2"), + _text(4, "V1.2.3"), + _text(5, "V1.0"), + _text(6, "LixelKity K1"), + _text(7, "K1SERIAL01"), + _text(8, "K1"), + ) + ) + working_status = _uint(1, 1) + _uint(2, 0) + device_info = _bytes(2, base_info) + _bytes(7, working_status) + error = _uint(1, OPENAPI_SUCCESS) + _text(2, "success") + return _bytes(1, header) + _bytes(2, device_info) + _bytes(15, error) + + +def test_exact_clean_cycle_pre_start_order_and_wire_lengths() -> None: + plan = build_shadow_bootstrap( + _authority(), + _binding(), + epoch_seconds=1_752_680_000, + timezone_name="Europe/Moscow", + ) + + assert [request.message_type for request in plan.requests] == [ + "DeviceInfoRequest", + "ModelingStatusRequest", + "GetRtkAdvanceRequest", + "DeviceConfigRequest", + "DeviceInfoRequest", + "GetRtkAdvanceRequest", + "GetNtripProfileRequest", + "GetCloudServerConfigRequest", + "GetRtkAdvanceRequest", + "DeviceInfoRequest", + ] + assert [request.payload_bytes for request in plan.requests] == [ + 60, + 64, + 63, + 195, + 135, + 138, + 140, + 145, + 138, + 135, + ] + assert sum(request.mutates_device for request in plan.requests) == 1 + assert plan.requests[3].mutates_device + assert plan.executable is False + assert plan.blockers == ("vendor-writes-disabled", "publisher-not-installed") + assert plan.retry_policy == "never-automatic" + assert APPLICATION_KEY not in repr(plan) + assert APPLICATION_KEY not in str(plan.as_dict()) + + +def test_bootstrap_headers_switch_from_unbound_to_live_device_identity() -> None: + plan = build_shadow_bootstrap( + _authority(), + _binding(), + epoch_seconds=1_752_680_000, + timezone_name="Europe/Moscow", + ) + + first_header = next(iter_fields(plan.requests[0].payload)).value + assert isinstance(first_header, bytes) + first_fields = {field.number: field.value for field in iter_fields(first_header)} + assert 4 not in first_fields + assert first_fields[5] == b":DeviceInfoRequest" + + config_header = next(iter_fields(plan.requests[3].payload)).value + assert isinstance(config_header, bytes) + config_fields = {field.number: field.value for field in iter_fields(config_header)} + assert config_fields[4] == VENDOR_DEVICE_ID.encode() + assert ( + config_fields[5] + == (f"{VENDOR_DEVICE_ID}:DeviceConfigRequest:Publish_Proto_DeviceConfig_SetTime").encode() + ) + + +def test_device_info_response_produces_live_binding_without_a_saved_device_profile() -> None: + response = decode_and_bind_device_info_response(_device_info_response(), _authority()) + + assert response.result_code == OPENAPI_SUCCESS + assert response.binding.ready_for_reviewed_profile + assert response.binding.matches_reviewed_firmware + assert response.binding.is_activated + assert VENDOR_DEVICE_ID not in repr(response) + assert APPLICATION_KEY not in repr(response) + + +def test_device_info_response_correlation_and_profile_attestation_fail_closed() -> None: + with pytest.raises(ApplicationBootstrapError, match="session correlation"): + decode_and_bind_device_info_response( + _device_info_response(session_id=":AnotherRequest"), + _authority(), + ) + + with pytest.raises(ApplicationBootstrapError, match="does not attest"): + build_shadow_bootstrap( + _authority(), + _binding(system_version="V3.0.3"), + epoch_seconds=1_752_680_000, + timezone_name="Europe/Moscow", + ) diff --git a/tests/test_xgrids_modeling_safety.py b/tests/test_xgrids_modeling_safety.py index dca8f7e..ac2dd66 100644 --- a/tests/test_xgrids_modeling_safety.py +++ b/tests/test_xgrids_modeling_safety.py @@ -2,6 +2,10 @@ from __future__ import annotations import pytest +from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import ( + ApplicationControlAuthority, + LiveDeviceControlBinding, +) from k1link.device_plugins.xgrids_k1.protocol.modeling_control import ( MODELING_STATE_BASE, ModelingAction, @@ -9,7 +13,6 @@ from k1link.device_plugins.xgrids_k1.protocol.modeling_control import ( ) from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import ( DEVICE_STATUS_TOPIC, - DeviceBoundControlEnrollment, LiveModelingControlSafety, ModelingControlSafetyError, ) @@ -61,14 +64,22 @@ def _status_message( ) -def _enrollment(**changes: str) -> DeviceBoundControlEnrollment: +def _authority() -> ApplicationControlAuthority: + return ApplicationControlAuthority(openapi_key="application-private-key") + + +def _binding(**changes: object) -> LiveDeviceControlBinding: values = { "vendor_device_id": "vendor-device-001", "device_serial": "serial-001", - "openapi_key": "device-bound-private-key", + "software_version": "V3.0.2-build.1", + "system_version": "V3.0.2", + "device_model": "LixelKity K1", + "device_type": "K1", + "is_activated": True, } values.update(changes) - return DeviceBoundControlEnrollment(**values) + return LiveDeviceControlBinding(**values) # type: ignore[arg-type] def test_live_status_identity_is_observed_without_accepting_replay() -> None: @@ -95,7 +106,7 @@ def test_start_shadow_plan_is_exact_bounded_and_never_executable() -> None: safety = LiveModelingControlSafety() safety.observe(_status_message(SessionState.READY), BridgeMetrics()) - plan = safety.plan_start(_enrollment(), project_name="SAFE_PROJECT") + plan = safety.plan_start(_authority(), _binding(), project_name="SAFE_PROJECT") fields = {field.number: field for field in iter_fields(plan.command.payload, max_fields=16)} assert plan.action is ModelingAction.START @@ -109,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 "device-bound-private-key" not in repr(plan) - assert "device-bound-private-key" not in str(plan.as_dict()) + assert "application-private-key" not in repr(plan) + assert "application-private-key" not in str(plan.as_dict()) def test_stop_shadow_plan_requires_same_live_device_and_active_project() -> None: @@ -120,13 +131,13 @@ def test_stop_shadow_plan_requires_same_live_device_and_active_project() -> None BridgeMetrics(), ) - plan = safety.plan_stop(_enrollment()) + plan = safety.plan_stop(_authority(), _binding()) fields = list(iter_fields(plan.command.payload, max_fields=8)) assert plan.action is ModelingAction.STOP assert [field.number for field in fields] == [1, 2] with pytest.raises(ModelingControlSafetyError, match="does not match"): - safety.plan_stop(_enrollment(device_serial="another-serial")) + safety.plan_stop(_authority(), _binding(device_serial="another-serial")) def test_identity_drift_and_decode_failure_fail_closed() -> None: @@ -139,7 +150,7 @@ def test_identity_drift_and_decode_failure_fail_closed() -> None: ) assert safety.snapshot().identity_conflict with pytest.raises(ModelingControlSafetyError, match="conflicted"): - safety.plan_start(_enrollment(), project_name="SAFE_PROJECT") + safety.plan_start(_authority(), _binding(), project_name="SAFE_PROJECT") safety.reset() malformed = StreamMessage( @@ -153,23 +164,31 @@ def test_identity_drift_and_decode_failure_fail_closed() -> None: assert safety.observe(malformed, metrics) assert safety.snapshot().decode_errors == 1 with pytest.raises(ModelingControlSafetyError, match="conflicted"): - safety.plan_start(_enrollment(), project_name="SAFE_PROJECT") + safety.plan_start(_authority(), _binding(), project_name="SAFE_PROJECT") def test_shadow_plan_requires_exact_safe_lifecycle_state() -> None: safety = LiveModelingControlSafety() safety.observe(_status_message(SessionState.SCAN_STARTING), BridgeMetrics()) with pytest.raises(ModelingControlSafetyError, match="must be ready"): - safety.plan_start(_enrollment(), project_name="SAFE_PROJECT") + safety.plan_start(_authority(), _binding(), project_name="SAFE_PROJECT") with pytest.raises(ModelingControlSafetyError, match="must be scanning"): - safety.plan_stop(_enrollment()) + safety.plan_stop(_authority(), _binding()) -def test_enrollment_rejects_runtime_profile_substitution() -> None: - with pytest.raises(ModelingControlSafetyError, match="exact reviewed K1 profile"): - DeviceBoundControlEnrollment( - vendor_device_id="vendor-device-001", - device_serial="serial-001", - openapi_key="device-bound-private-key", - firmware_version="3.0.3", # type: ignore[arg-type] +def test_device_info_binding_rejects_unreviewed_or_inactive_profile() -> None: + safety = LiveModelingControlSafety() + safety.observe(_status_message(SessionState.READY), BridgeMetrics()) + + with pytest.raises(ModelingControlSafetyError, match="does not attest"): + safety.plan_start( + _authority(), + _binding(software_version="V3.0.3"), + project_name="SAFE_PROJECT", + ) + with pytest.raises(ModelingControlSafetyError, match="does not attest"): + safety.plan_start( + _authority(), + _binding(is_activated=False), + project_name="SAFE_PROJECT", )