diff --git a/doc/APIClientRegen.md b/doc/APIClientRegen.md index 35ff6a97..2a7e31db 100644 --- a/doc/APIClientRegen.md +++ b/doc/APIClientRegen.md @@ -1,11 +1,4 @@ # Regenerating the API Client 1. Download the latest API specification from https://api.infuse-iot.com/docs -2. Delete the previous API client: `rm -r ./src/infuse-iot/api_client` -3. Generate API client into the root directory: `openapi-python-client generate --path ./infuse-api.yaml` -4. Move API client to desired directory: `mv infuse-api-client/infuse_api_client/ ./src/infuse_iot/api_client/` -5. Move README: `mv infuse-api-client/README.md ./src/infuse_iot/api_client/` -6. Remove extraneous files: `rm -r infuse-api-client` -7. Manually fixup `README.md` for naming - -Some of these steps can possibly be automated with the `openapi-python-client` `--config` parameter in the future. +2. Run the regeneration script `./scripts/regenrate_api_client.py /path/to/infuse-api.yaml` diff --git a/scripts/regenerate_api_client.py b/scripts/regenerate_api_client.py new file mode 100755 index 00000000..2b00e3fa --- /dev/null +++ b/scripts/regenerate_api_client.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Regenerate the Infuse-IoT OpenAPI client.""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +TARGET_CLIENT = Path("src/infuse_iot/api_client") +GENERATED_PACKAGE = "infuse_api_client" + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def validate_spec_path(spec_path: Path) -> None: + if not spec_path.exists(): + raise RuntimeError(f"API specification does not exist: {spec_path}") + if not spec_path.is_file(): + raise RuntimeError(f"API specification is not a file: {spec_path}") + + +def run_generator(spec_path: Path, staging_dir: Path) -> Path: + command = ["openapi-python-client", "generate", "--path", str(spec_path)] + print(f"Generating API client in {staging_dir}") + try: + subprocess.run(command, cwd=staging_dir, check=True) + except FileNotFoundError as exc: + raise RuntimeError("openapi-python-client was not found. Install it and rerun this script.") from exc + except subprocess.CalledProcessError as exc: + raise RuntimeError(f"openapi-python-client failed with exit code {exc.returncode}") from exc + + matches = sorted(staging_dir.glob(f"*/{GENERATED_PACKAGE}")) + if not matches: + raise RuntimeError(f"generated package {GENERATED_PACKAGE!r} was not found under {staging_dir}") + if len(matches) > 1: + raise RuntimeError( + "multiple generated client packages were found: " + ", ".join(str(match) for match in matches) + ) + return matches[0] + + +def replace_client(generated_client: Path, target_client: Path) -> None: + readme = None + readme_path = target_client / "README.md" + if readme_path.exists(): + readme = readme_path.read_bytes() + + target_parent = target_client.parent + with tempfile.TemporaryDirectory(prefix="api-client-backup-", dir=target_parent) as backup: + backup_client = Path(backup) / target_client.name + if target_client.exists(): + shutil.move(str(target_client), backup_client) + + try: + shutil.move(str(generated_client), target_client) + if readme is not None: + (target_client / "README.md").write_bytes(readme) + except Exception: + if target_client.exists(): + shutil.rmtree(target_client) + if backup_client.exists(): + shutil.move(str(backup_client), target_client) + raise + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Regenerate src/infuse_iot/api_client from an OpenAPI YAML file.") + parser.add_argument( + "spec_path", + help="Path to the downloaded OpenAPI YAML file.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv if argv is not None else sys.argv[1:]) + root = repo_root() + spec_path = Path(args.spec_path).expanduser() + if not spec_path.is_absolute(): + spec_path = root / spec_path + spec_path = spec_path.resolve() + target_client = root / TARGET_CLIENT + + try: + validate_spec_path(spec_path) + + with tempfile.TemporaryDirectory(prefix="api-client-gen-", dir=root) as staging: + generated_client = run_generator(spec_path, Path(staging)) + replace_client(generated_client, target_client) + except RuntimeError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + print(f"Regenerated {target_client.relative_to(root)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/infuse_iot/api_client/api/key/get_device_shared_secret.py b/src/infuse_iot/api_client/api/key/get_device_shared_secret.py new file mode 100644 index 00000000..ac6a9530 --- /dev/null +++ b/src/infuse_iot/api_client/api/key/get_device_shared_secret.py @@ -0,0 +1,156 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.get_device_shared_secret_body import GetDeviceSharedSecretBody +from ...models.key import Key +from ...types import Response + + +def _get_kwargs( + *, + body: GetDeviceSharedSecretBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/key/sharedSecret/device", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Key | None: + if response.status_code == 200: + response_200 = Key.from_dict(response.json()) + + return response_200 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Key]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: GetDeviceSharedSecretBody, +) -> Response[Key]: + """Get a device's shared secret key + + Args: + body (GetDeviceSharedSecretBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Key] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + body: GetDeviceSharedSecretBody, +) -> Key | None: + """Get a device's shared secret key + + Args: + body (GetDeviceSharedSecretBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Key + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: GetDeviceSharedSecretBody, +) -> Response[Key]: + """Get a device's shared secret key + + Args: + body (GetDeviceSharedSecretBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Key] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: GetDeviceSharedSecretBody, +) -> Key | None: + """Get a device's shared secret key + + Args: + body (GetDeviceSharedSecretBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Key + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/src/infuse_iot/api_client/models/__init__.py b/src/infuse_iot/api_client/models/__init__.py index 7687be44..72df52cc 100644 --- a/src/infuse_iot/api_client/models/__init__.py +++ b/src/infuse_iot/api_client/models/__init__.py @@ -82,6 +82,7 @@ from .generate_mqtt_token_body import GenerateMQTTTokenBody from .generated_api_key import GeneratedAPIKey from .generated_mqtt_token import GeneratedMQTTToken +from .get_device_shared_secret_body import GetDeviceSharedSecretBody from .get_last_routes_for_devices_body import GetLastRoutesForDevicesBody from .health_check import HealthCheck from .interface_data import InterfaceData @@ -208,6 +209,7 @@ "GeneratedAPIKey", "GeneratedMQTTToken", "GenerateMQTTTokenBody", + "GetDeviceSharedSecretBody", "GetLastRoutesForDevicesBody", "HealthCheck", "InterfaceData", diff --git a/src/infuse_iot/api_client/models/get_device_shared_secret_body.py b/src/infuse_iot/api_client/models/get_device_shared_secret_body.py new file mode 100644 index 00000000..24d3cfc7 --- /dev/null +++ b/src/infuse_iot/api_client/models/get_device_shared_secret_body.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.security_state import SecurityState + + +T = TypeVar("T", bound="GetDeviceSharedSecretBody") + + +@_attrs_define +class GetDeviceSharedSecretBody: + """ + Attributes: + device_id (str): The ID of the device as a hex string Example: d291d4d66bf0a955. + security_state (SecurityState | Unset): + """ + + device_id: str + security_state: SecurityState | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + device_id = self.device_id + + security_state: dict[str, Any] | Unset = UNSET + if not isinstance(self.security_state, Unset): + security_state = self.security_state.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "deviceId": device_id, + } + ) + if security_state is not UNSET: + field_dict["securityState"] = security_state + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.security_state import SecurityState + + d = dict(src_dict) + device_id = d.pop("deviceId") + + _security_state = d.pop("securityState", UNSET) + security_state: SecurityState | Unset + if isinstance(_security_state, Unset): + security_state = UNSET + else: + security_state = SecurityState.from_dict(_security_state) + + get_device_shared_secret_body = cls( + device_id=device_id, + security_state=security_state, + ) + + get_device_shared_secret_body.additional_properties = d + return get_device_shared_secret_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/infuse_iot/database.py b/src/infuse_iot/database.py index aec41e7a..68cbef1f 100644 --- a/src/infuse_iot/database.py +++ b/src/infuse_iot/database.py @@ -11,8 +11,9 @@ from cryptography.hazmat.primitives.asymmetric import x25519 from infuse_iot.api_client import Client -from infuse_iot.api_client.api.key import get_shared_secret -from infuse_iot.api_client.models import Key +from infuse_iot.api_client.api.key import get_device_shared_secret +from infuse_iot.api_client.models.get_device_shared_secret_body import GetDeviceSharedSecretBody +from infuse_iot.api_client.models.security_state import SecurityState from infuse_iot.credentials import get_api_key, load_network from infuse_iot.epacket.interface import Address as InterfaceAddress from infuse_iot.util.crypto import hkdf_derive @@ -130,7 +131,7 @@ def observe_secondary_remote_public_key(self, infuse_id: int, secondary_pub_key: assert self._local_root_public is not None assert dev.device_public_key is not None device_public_key = x25519.X25519PublicKey.from_public_bytes(dev.device_public_key) - dev.secondary_device_key_id = binascii.crc32(self._local_root_public + dev.device_public_key) & 0xFFFFFF + dev.secondary_device_key_id = self.get_device_key_id(self._local_root_public, dev.device_public_key) dev.local_shared_key = self._local_root.exchange(device_public_key) @contextmanager @@ -156,12 +157,19 @@ def _from_cache(self, infuse_id: int, device_pub_key: bytes) -> bytes | None: return state["shared_key"] def observe_security_state( - self, infuse_id: int, cloud_pub_key: bytes, device_pub_key: bytes, network_id: int + self, + infuse_id: int, + cloud_pub_key: bytes, + device_pub_key: bytes, + network_id: int, + challenge: bytes, + challenge_resp_type: int, + challenge_resp: bytes, ) -> None: """Update device state based on security_state response""" if infuse_id not in self.devices: self.devices[infuse_id] = self.DeviceState(infuse_id) - device_key_id = binascii.crc32(cloud_pub_key + device_pub_key) & 0x00FFFFFF + device_key_id = self.get_device_key_id(cloud_pub_key, device_pub_key) self.devices[infuse_id].device_key_id = device_key_id self.devices[infuse_id].network_id = network_id self.devices[infuse_id].device_public_key = device_pub_key @@ -174,13 +182,26 @@ def observe_security_state( client = Client(base_url="https://api.infuse-iot.com").with_headers({"x-api-key": f"Bearer {get_api_key()}"}) with client as client: - body = Key(base64.b64encode(device_pub_key).decode("utf-8")) - response = get_shared_secret.sync(client=client, body=body) + security_state = SecurityState( + base64.b64encode(cloud_pub_key).decode("utf-8"), + base64.b64encode(device_pub_key).decode("utf-8"), + network_id, + base64.b64encode(challenge).decode("utf-8"), + challenge_resp_type, + base64.b64encode(challenge_resp).decode("utf-8"), + ) + body = GetDeviceSharedSecretBody(f"{infuse_id:016x}", security_state) + response = get_device_shared_secret.sync(client=client, body=body) if response is not None: key = base64.b64decode(response.key) self.devices[infuse_id].shared_key = key self._update_cache(infuse_id, device_pub_key, key) + @staticmethod + def get_device_key_id(cloud_pub_key: bytes, device_pub_key: bytes) -> int: + """Get device key ID for a given cloud and device public key""" + return binascii.crc32(cloud_pub_key + device_pub_key) & 0x00FFFFFF + def _network_key(self, network_id: int, interface: bytes, gps_time: int) -> bytes: if network_id not in self._network_keys: try: @@ -203,6 +224,12 @@ def has_public_key(self, infuse_id: int) -> bool: return False return self.devices[infuse_id].device_public_key is not None + def has_shared_key(self, infuse_id: int) -> bool: + """Does the database have the shared key for this device?""" + if infuse_id not in self.devices: + return False + return self.devices[infuse_id].shared_key is not None + def has_network_id(self, infuse_id: int) -> bool: """Does the database know the network ID for this device?""" if infuse_id not in self.devices: diff --git a/src/infuse_iot/tools/cloud.py b/src/infuse_iot/tools/cloud.py index c3ea22ef..0bc1b442 100644 --- a/src/infuse_iot/tools/cloud.py +++ b/src/infuse_iot/tools/cloud.py @@ -267,6 +267,8 @@ def info(self, client: Client): ("~~~Latest Route~~~", ""), ("Interface", route.interface.upper()), ] + if route.forwarded: + table += [("Forwarded From", f"{route.forwarded.device_id} ({route.forwarded.rssi} dBm)")] if route.bt_adv: table += [("BT Address", f"{route.bt_adv.address} ({route.bt_adv.type_})")] if route.udp: diff --git a/src/infuse_iot/tools/gateway.py b/src/infuse_iot/tools/gateway.py index da86bf12..5600f30d 100644 --- a/src/infuse_iot/tools/gateway.py +++ b/src/infuse_iot/tools/gateway.py @@ -14,7 +14,6 @@ import sys import threading import time -from collections.abc import Callable import cryptography import cryptography.exceptions @@ -54,83 +53,11 @@ ) from infuse_iot.util.argparse import ValidFile, add_server_port_parser from infuse_iot.util.console import Console +from infuse_iot.util.local_rpc_server import LocalRpcServer from infuse_iot.util.os import is_wsl from infuse_iot.util.threading import SignaledThread -class LocalRpcServer: - """Basic class supporting locally generated commands""" - - def __init__(self, database: DeviceDatabase): - self._cnt = random.randint(0, 2**31) - self._ddb = database - self._queued: dict[int, Callable | None] = {} - - def generate(self, command: int, args: bytes, auth: Auth, cb: Callable | None) -> PacketOutputRouted: - """Generate RPC packet from arguments""" - cmd_bytes = bytes(rpc.RequestHeader(self._cnt, command)) + args - cmd_pkt = PacketOutputRouted( - [HopOutput.serial(auth)], - InfuseType.RPC_CMD, - cmd_bytes, - ) - assert self._ddb.gateway is not None - cmd_pkt.route[0].infuse_id = self._ddb.gateway - self._queued[self._cnt] = cb - self._cnt += 1 - return cmd_pkt - - def generate_remote_bt( - self, remote: int, command: int, args: bytes, auth: Auth, cb: Callable | None - ) -> PacketOutputRouted: - """Generate RPC packet for Bluetooth remote from arguments""" - cmd_bytes = bytes(rpc.RequestHeader(self._cnt, command)) + args - - assert self._ddb.gateway is not None - serial = HopOutput(self._ddb.gateway, interface.ID.SERIAL, Auth.DEVICE) - bt = HopOutput(remote, interface.ID.BT_CENTRAL, auth) - self._queued[self._cnt] = cb - self._cnt += 1 - return PacketOutputRouted( - [serial, bt], - InfuseType.RPC_CMD, - cmd_bytes, - ) - - def handle(self, pkt: PacketReceived): - """Handle received packets""" - # Only care about RPC responses - if pkt.ptype != InfuseType.RPC_RSP: - return - - # Inspect the response header - header = rpc.ResponseHeader.from_buffer_copy(pkt.payload) - - # Was this a BT connect response with key information? - if header.command_id == defs.bt_connect_infuse.COMMAND_ID: - resp = defs.bt_connect_infuse.response.from_buffer_copy(pkt.payload[ctypes.sizeof(header) :]) - if_addr = interface.Address.BluetoothLeAddr.from_rpc_struct(resp.peer) - infuse_id = self._ddb.infuse_id_from_bluetooth(if_addr) - if infuse_id is None: - Console.log_error(f"Infuse ID of {if_addr} not known") - elif header.return_code == 0: - self._ddb.observe_security_state( - infuse_id, - bytes(resp.cloud_public_key), - bytes(resp.device_public_key), - resp.network_id, - ) - - # Determine if the response is to a command we initiated - if header.request_id not in self._queued: - return - - # Run the callback - cb = self._queued.pop(header.request_id) - if cb is not None: - cb(pkt, header.return_code, pkt.payload[ctypes.sizeof(header) :]) - - class CommonThreadState: def __init__( self, @@ -148,23 +75,27 @@ def notification_broadcast(self, notification: ClientNotification): if self.server: self.server.broadcast(notification) - def query_device_key(self, cb_event: threading.Event | None = None): - def security_state_done(pkt: PacketReceived, _: int, response: bytes): - cloud_key = response[:32] - device_key = response[32:64] - network_id = int.from_bytes(response[64:68], "little") - - self.ddb.observe_security_state(pkt.route[0].infuse_id, cloud_key, device_key, network_id) + def query_device_key(self, infuse_id: int, cb_event: threading.Event | None = None): + def security_state_done(pkt: PacketReceived, _rc: int, response: bytes, challenge): + decoded = defs.security_state.response.vla_from_buffer_copy(response) + self.ddb.observe_security_state( + infuse_id, + bytes(decoded.cloud_public_key), + bytes(decoded.device_public_key), + decoded.network_id, + challenge, + decoded.challenge_response_type, + bytes(decoded.challenge_response), + ) if cb_event is not None: cb_event.set() - def public_keys_done(pkt: PacketReceived, rc: int, response: bytes): + def public_keys_done(pkt: PacketReceived, rc: int, response: bytes, _): if rc != 0: return decoded = defs.security_public_keys.response.vla_from_buffer_copy(response) for key in decoded.public_keys: if key.id == defs.rpc_enum_key_id.SECONDARY_REMOTE_PUBLIC_KEY: - infuse_id = pkt.route[0].infuse_id self.ddb.observe_secondary_remote_public_key(infuse_id, bytes(key.key)) def run_cmd_pkt(cmd_pkt: PacketOutputRouted): @@ -177,14 +108,17 @@ def run_cmd_pkt(cmd_pkt: PacketOutputRouted): cb_event.wait(1.0) # Run security_state RPC - cmd_pkt = self.rpc.generate( - defs.security_state.COMMAND_ID, random.randbytes(16), Auth.NETWORK, security_state_done + challenge = random.randbytes(16) + cmd_pkt = self.rpc.generate_addressed( + infuse_id, defs.security_state.COMMAND_ID, challenge, Auth.NETWORK, security_state_done, challenge ) run_cmd_pkt(cmd_pkt) if self.ddb.has_local_root: # Query other public keys from the device - cmd_pkt = self.rpc.generate(defs.security_public_keys.COMMAND_ID, b"\x00", Auth.NETWORK, public_keys_done) + cmd_pkt = self.rpc.generate_addressed( + infuse_id, defs.security_public_keys.COMMAND_ID, b"\x00", Auth.NETWORK, public_keys_done, None + ) run_cmd_pkt(cmd_pkt) @@ -263,7 +197,7 @@ def _handle_serial_frame(self, frame: bytearray): else: Console.log_info(f"Dropping {len(frame)} byte packet...") else: - self._common.query_device_key(None) + self._common.query_device_key(self._common.ddb.gateway, None) Console.log_info(f"Dropping {len(frame)} byte packet to query device key...") return except cryptography.exceptions.InvalidTag as e: @@ -283,7 +217,8 @@ def _handle_serial_frame(self, frame: bytearray): self._handle_memfault_pkt(pkt) # Proactively requery keys elif pkt.ptype == InfuseType.KEY_IDS: - self._common.query_device_key(None) + assert self._common.ddb.gateway is not None + self._common.query_device_key(self._common.ddb.gateway, None) # Forward to clients notification = ClientNotificationEpacketReceived(pkt) @@ -334,9 +269,9 @@ def _handle_epacket_send(self, req: GatewayRequestEpacketSend): # Do we have the device public keys we need? for hop in routed.route: - if hop.auth == Auth.DEVICE and not self._common.ddb.has_public_key(hop.infuse_id): + if hop.auth == Auth.DEVICE and not self._common.ddb.has_shared_key(hop.infuse_id): cb_event = threading.Event() - self._common.query_device_key(cb_event) + self._common.query_device_key(hop.infuse_id, cb_event) # Encode and encrypt payload encrypted = routed.to_serial(self._common.ddb) @@ -349,7 +284,7 @@ def _connected_notification(self, infuse_id: int): rsp = ClientNotificationConnectionCreated(infuse_id, 244 - ctypes.sizeof(CtypeBtGattFrame) - 16) self._common.notification_broadcast(rsp) - def _pub_keys_cb(self, pkt: PacketReceived, rc: int, response: bytes): + def _pub_keys_cb(self, pkt: PacketReceived, rc: int, response: bytes, _): infuse_id = pkt.route[0].infuse_id if rc == 0: decoded = defs.security_public_keys.response.vla_from_buffer_copy(response) @@ -360,7 +295,7 @@ def _pub_keys_cb(self, pkt: PacketReceived, rc: int, response: bytes): # Notify connection success self._connected_notification(infuse_id) - def _bt_connect_cb(self, pkt: PacketReceived, rc: int, response: bytes): + def _bt_connect_cb(self, pkt: PacketReceived, rc: int, response: bytes, _): resp = defs.bt_connect_infuse.response.from_buffer_copy(pkt.payload[ctypes.sizeof(rpc.ResponseHeader) :]) if_addr = interface.Address.BluetoothLeAddr.from_rpc_struct(resp.peer) infuse_id = self._common.ddb.infuse_id_from_bluetooth(if_addr) @@ -381,7 +316,7 @@ def _bt_connect_cb(self, pkt: PacketReceived, rc: int, response: bytes): if self._common.ddb.has_local_root: # Query public keys before running callback cmd = self._common.rpc.generate_remote_bt( - infuse_id, defs.security_public_keys.COMMAND_ID, b"\x00", Auth.NETWORK, self._pub_keys_cb + infuse_id, defs.security_public_keys.COMMAND_ID, b"\x00", Auth.NETWORK, self._pub_keys_cb, None ) encrypted = cmd.to_serial(self._common.ddb) Console.log_tx(cmd.ptype, len(encrypted)) @@ -423,11 +358,8 @@ def _handle_conn_request(self, req: GatewayRequestConnectionRequest): subs, 0, ) - cmd = self._common.rpc.generate( - defs.bt_connect_infuse.COMMAND_ID, - bytes(connect_args), - Auth.DEVICE, - self._bt_connect_cb, + cmd = self._common.rpc.generate_serial( + defs.bt_connect_infuse.COMMAND_ID, bytes(connect_args), Auth.DEVICE, self._bt_connect_cb, None ) encrypted = cmd.to_serial(self._common.ddb) Console.log_tx(cmd.ptype, len(encrypted)) @@ -452,7 +384,9 @@ def _handle_conn_release(self, req: GatewayRequestConnectionRelease): self._connected.pop(req.infuse_id) disconnect_args = defs.bt_disconnect.request(state.bt_addr.to_rpc_struct()) - cmd = self._common.rpc.generate(defs.bt_disconnect.COMMAND_ID, bytes(disconnect_args), Auth.DEVICE, None) + cmd = self._common.rpc.generate_serial( + defs.bt_disconnect.COMMAND_ID, bytes(disconnect_args), Auth.DEVICE, None, None + ) encrypted = cmd.to_serial(self._common.ddb) Console.log_tx(cmd.ptype, len(encrypted)) self._common.port.write(encrypted) diff --git a/src/infuse_iot/tools/native_bt.py b/src/infuse_iot/tools/native_bt.py index 7a1b8e2d..f1008d22 100644 --- a/src/infuse_iot/tools/native_bt.py +++ b/src/infuse_iot/tools/native_bt.py @@ -9,6 +9,7 @@ import asyncio import ctypes import json +import random from typing import Any from bleak import BleakClient, BleakScanner @@ -17,6 +18,7 @@ from bleak.backends.scanner import AdvertisementData from cryptography.exceptions import InvalidTag +import infuse_iot.definitions.rpc as defs from infuse_iot.commands import InfuseCommand from infuse_iot.common import InfuseBluetoothUUID, InfuseType from infuse_iot.database import DeviceDatabase, UnknownNetworkError @@ -46,6 +48,7 @@ ) from infuse_iot.util.argparse import BtLeAddress, ValidFile, add_server_port_parser from infuse_iot.util.console import Console +from infuse_iot.util.local_rpc_server import LocalRpcServer class InfuseGattReadResponse(ctypes.LittleEndianStructure): @@ -66,6 +69,7 @@ def __init__(self, database: DeviceDatabase, server: LocalServer, bleak_mapping: self._mapping = bleak_mapping self._queues: dict[int, asyncio.Queue] = {} self._tasks: dict[int, asyncio.Task] = {} + self._rpc = LocalRpcServer(database, native_bt=True) def wrapped_broadcast(self, notifcation: ClientNotification): try: @@ -98,35 +102,84 @@ def notification_handler(self, _characteristic: BleakGATTCharacteristic, data: b bytes(decr), ) Console.log_rx(pkt.ptype, len(data)) + # Handle any local RPC responses + self._rpc.handle(pkt) + # Forward to clients self.wrapped_broadcast(ClientNotificationEpacketReceived(pkt)) async def create_connection_internal( self, request: GatewayRequestConnectionRequest, dev: BLEDevice, queue: asyncio.Queue ): - Console.log_info(f"{dev}: Initiating connection") + command_notify_enabled = False + Console.log_info(f"{request.infuse_id:016x}: Initiating connection") async with BleakClient(dev, timeout=request.timeout_ms / 1000) as client: # Modified from bleak example code if client._backend.__class__.__name__ == "BleakClientBlueZDBus": await client._backend._acquire_mtu() # type: ignore - security_info = await client.read_gatt_char(InfuseBluetoothUUID.COMMAND_CHAR) - resp = InfuseGattReadResponse.from_buffer_copy(security_info) - self._db.observe_security_state( - request.infuse_id, - bytes(resp.cloud_public_key), - bytes(resp.device_public_key), - resp.network_id, - ) + Console.log_info(f"{request.infuse_id:016x}: Connected (MTU {client.mtu_size})") + + have_shared_key = self._db.has_shared_key(request.infuse_id) + if have_shared_key: + # Read the current keys back to confirm they haven't changed + security_info = await client.read_gatt_char(InfuseBluetoothUUID.COMMAND_CHAR) + resp = InfuseGattReadResponse.from_buffer_copy(security_info) + key_id = self._db.get_device_key_id(bytes(resp.cloud_public_key), bytes(resp.device_public_key)) + if self._db.devices[request.infuse_id].device_key_id != key_id: + # Keys mismatch, invaidate the shared key + Console.log_info(f"{dev}: Key mismatch, re-running derivation") + have_shared_key = False + + if not have_shared_key: + # Always need the command characteristic to get the response + await client.start_notify(InfuseBluetoothUUID.COMMAND_CHAR, self.notification_handler) + command_notify_enabled = True + + security_state_received = asyncio.Event() + + def security_state_done(pkt: PacketReceived, _rc: int, response: bytes, challenge): + decoded = defs.security_state.response.vla_from_buffer_copy(response) + self._db.observe_security_state( + request.infuse_id, + bytes(decoded.cloud_public_key), + bytes(decoded.device_public_key), + decoded.network_id, + challenge, + decoded.challenge_response_type, + bytes(decoded.challenge_response), + ) + security_state_received.set() + + # Construct the Security State RPC command + challenge = random.randbytes(16) + ss_pkt = self._rpc.generate_addressed( + request.infuse_id, + defs.security_state.COMMAND_ID, + challenge, + Auth.NETWORK, + security_state_done, + challenge, + ) - if request.data_types & request.DataType.COMMAND: + # Encrypt command and write to remote + encr = CtypeBtGattFrame.encrypt(self._db, request.infuse_id, ss_pkt.ptype, Auth.NETWORK, ss_pkt.payload) + Console.log_tx(ss_pkt.ptype, len(encr)) + await client.write_gatt_char(InfuseBluetoothUUID.COMMAND_CHAR, encr, response=False) + + # Wait for a response + await asyncio.wait_for(security_state_received.wait(), timeout=request.timeout_ms / 1000) + + # Disable the command characteristic if not requested + if not (request.data_types & request.DataType.COMMAND): + await client.stop_notify(InfuseBluetoothUUID.COMMAND_CHAR) + + if (request.data_types & request.DataType.COMMAND) and not command_notify_enabled: await client.start_notify(InfuseBluetoothUUID.COMMAND_CHAR, self.notification_handler) if request.data_types & request.DataType.DATA: await client.start_notify(InfuseBluetoothUUID.DATA_CHAR, self.notification_handler) if request.data_types & request.DataType.LOGGING: await client.start_notify(InfuseBluetoothUUID.LOGGING_CHAR, self.notification_handler) - Console.log_info(f"{dev}: Connected (MTU {client.mtu_size})") - self.wrapped_broadcast( ClientNotificationConnectionCreated( request.infuse_id, diff --git a/src/infuse_iot/util/local_rpc_server.py b/src/infuse_iot/util/local_rpc_server.py new file mode 100644 index 00000000..67c2c060 --- /dev/null +++ b/src/infuse_iot/util/local_rpc_server.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 + +"""Simple local RPC server implementation""" + +import ctypes +import random +import typing +from collections.abc import Callable + +import infuse_iot.definitions.rpc as defs +import infuse_iot.epacket.interface as interface +from infuse_iot import rpc +from infuse_iot.common import InfuseType +from infuse_iot.database import DeviceDatabase +from infuse_iot.epacket.packet import ( + Auth, + HopOutput, + PacketOutputRouted, + PacketReceived, +) +from infuse_iot.util.console import Console + +RpcCallback = Callable[[PacketReceived, int, bytes, typing.Any], None] + + +class LocalRpcServer: + """Basic class supporting locally generated commands""" + + def __init__(self, database: DeviceDatabase, native_bt: bool = False): + self._cnt = random.randint(0, 2**31) + self._ddb = database + self._native_bt = native_bt + self._queued: dict[int, tuple[RpcCallback | None, typing.Any]] = {} + + def generate_serial( + self, command: int, args: bytes, auth: Auth, cb: RpcCallback | None, cb_ctx: typing.Any + ) -> PacketOutputRouted: + """Generate RPC packet from arguments""" + cmd_bytes = bytes(rpc.RequestHeader(self._cnt, command)) + args + cmd_pkt = PacketOutputRouted( + [HopOutput.serial(auth)], + InfuseType.RPC_CMD, + cmd_bytes, + ) + assert self._ddb.gateway is not None + cmd_pkt.route[0].infuse_id = self._ddb.gateway + self._queued[self._cnt] = (cb, cb_ctx) + self._cnt += 1 + return cmd_pkt + + def generate_remote_bt_direct( + self, remote: int, command: int, args: bytes, auth: Auth, cb: RpcCallback | None, cb_ctx: typing.Any + ) -> PacketOutputRouted: + """Generate RPC packet for Bluetooth remote from arguments, without intermediate hops""" + cmd_bytes = bytes(rpc.RequestHeader(self._cnt, command)) + args + cmd_pkt = PacketOutputRouted( + [HopOutput(remote, interface.ID.BT_CENTRAL, auth)], + InfuseType.RPC_CMD, + cmd_bytes, + ) + self._queued[self._cnt] = (cb, cb_ctx) + self._cnt += 1 + return cmd_pkt + + def generate_remote_bt( + self, remote: int, command: int, args: bytes, auth: Auth, cb: RpcCallback | None, cb_ctx: typing.Any + ) -> PacketOutputRouted: + """Generate RPC packet for Bluetooth remote from arguments""" + cmd_bytes = bytes(rpc.RequestHeader(self._cnt, command)) + args + + assert self._ddb.gateway is not None + serial = HopOutput(self._ddb.gateway, interface.ID.SERIAL, Auth.DEVICE) + bt = HopOutput(remote, interface.ID.BT_CENTRAL, auth) + self._queued[self._cnt] = (cb, cb_ctx) + self._cnt += 1 + return PacketOutputRouted( + [serial, bt], + InfuseType.RPC_CMD, + cmd_bytes, + ) + + def generate_addressed( + self, address: int, command: int, args: bytes, auth: Auth, cb: RpcCallback | None, cb_ctx: typing.Any + ) -> PacketOutputRouted: + """Generate RPC packet for explicit address""" + if self._native_bt: + return self.generate_remote_bt_direct(address, command, args, auth, cb, cb_ctx) + if address == self._ddb.gateway: + return self.generate_serial(command, args, auth, cb, cb_ctx) + return self.generate_remote_bt(address, command, args, auth, cb, cb_ctx) + + def handle(self, pkt: PacketReceived): + """Handle received packets""" + # Only care about RPC responses + if pkt.ptype != InfuseType.RPC_RSP: + return + + # Inspect the response header + header = rpc.ResponseHeader.from_buffer_copy(pkt.payload) + + # Was this a BT connect response with key information? + if header.command_id == defs.bt_connect_infuse.COMMAND_ID: + resp = defs.bt_connect_infuse.response.from_buffer_copy(pkt.payload[ctypes.sizeof(header) :]) + if_addr = interface.Address.BluetoothLeAddr.from_rpc_struct(resp.peer) + infuse_id = self._ddb.infuse_id_from_bluetooth(if_addr) + if infuse_id is None: + Console.log_error(f"Infuse ID of {if_addr} not known") + + # Determine if the response is to a command we initiated + if header.request_id not in self._queued: + return + + # Run the callback + (cb, cb_ctx) = self._queued.pop(header.request_id) + if cb is not None: + cb(pkt, header.return_code, pkt.payload[ctypes.sizeof(header) :], cb_ctx)