Skip to content

Commit d0c210e

Browse files
committed
database: transition to get_device_shared_secret
Switch from `get_shared_secret` to `get_device_shared_secret`, which requires additional authentication information already exposed by the embedded devices. Both `gateway` and `native_bt` required updates to run an explicit RPC to get authentication information, instead of just reading the command characteristic. Signed-off-by: Jordan Yates <jordan@embeint.com>
1 parent 15e578c commit d0c210e

4 files changed

Lines changed: 143 additions & 36 deletions

File tree

src/infuse_iot/database.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
from cryptography.hazmat.primitives.asymmetric import x25519
1212

1313
from infuse_iot.api_client import Client
14-
from infuse_iot.api_client.api.key import get_shared_secret
15-
from infuse_iot.api_client.models import Key
14+
from infuse_iot.api_client.api.key import get_device_shared_secret
15+
from infuse_iot.api_client.models.get_device_shared_secret_body import GetDeviceSharedSecretBody
16+
from infuse_iot.api_client.models.security_state import SecurityState
1617
from infuse_iot.credentials import get_api_key, load_network
1718
from infuse_iot.epacket.interface import Address as InterfaceAddress
1819
from infuse_iot.util.crypto import hkdf_derive
@@ -156,7 +157,14 @@ def _from_cache(self, infuse_id: int, device_pub_key: bytes) -> bytes | None:
156157
return state["shared_key"]
157158

158159
def observe_security_state(
159-
self, infuse_id: int, cloud_pub_key: bytes, device_pub_key: bytes, network_id: int
160+
self,
161+
infuse_id: int,
162+
cloud_pub_key: bytes,
163+
device_pub_key: bytes,
164+
network_id: int,
165+
challenge: bytes,
166+
challenge_resp_type: int,
167+
challenge_resp: bytes,
160168
) -> None:
161169
"""Update device state based on security_state response"""
162170
if infuse_id not in self.devices:
@@ -174,8 +182,16 @@ def observe_security_state(
174182

175183
client = Client(base_url="https://api.infuse-iot.com").with_headers({"x-api-key": f"Bearer {get_api_key()}"})
176184
with client as client:
177-
body = Key(base64.b64encode(device_pub_key).decode("utf-8"))
178-
response = get_shared_secret.sync(client=client, body=body)
185+
security_state = SecurityState(
186+
base64.b64encode(cloud_pub_key).decode("utf-8"),
187+
base64.b64encode(device_pub_key).decode("utf-8"),
188+
network_id,
189+
base64.b64encode(challenge).decode("utf-8"),
190+
challenge_resp_type,
191+
base64.b64encode(challenge_resp).decode("utf-8"),
192+
)
193+
body = GetDeviceSharedSecretBody(f"{infuse_id:016x}", security_state)
194+
response = get_device_shared_secret.sync(client=client, body=body)
179195
if response is not None:
180196
key = base64.b64decode(response.key)
181197
self.devices[infuse_id].shared_key = key
@@ -208,6 +224,12 @@ def has_public_key(self, infuse_id: int) -> bool:
208224
return False
209225
return self.devices[infuse_id].device_public_key is not None
210226

227+
def has_shared_key(self, infuse_id: int) -> bool:
228+
"""Does the database have the shared key for this device?"""
229+
if infuse_id not in self.devices:
230+
return False
231+
return self.devices[infuse_id].shared_key is not None
232+
211233
def has_network_id(self, infuse_id: int) -> bool:
212234
"""Does the database know the network ID for this device?"""
213235
if infuse_id not in self.devices:

src/infuse_iot/tools/gateway.py

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,18 @@ def notification_broadcast(self, notification: ClientNotification):
7575
if self.server:
7676
self.server.broadcast(notification)
7777

78-
def query_device_key(self, cb_event: threading.Event | None = None):
78+
def query_device_key(self, infuse_id: int, cb_event: threading.Event | None = None):
7979
def security_state_done(pkt: PacketReceived, _rc: int, response: bytes, challenge):
80-
cloud_key = response[:32]
81-
device_key = response[32:64]
82-
network_id = int.from_bytes(response[64:68], "little")
83-
84-
self.ddb.observe_security_state(pkt.route[0].infuse_id, cloud_key, device_key, network_id)
80+
decoded = defs.security_state.response.vla_from_buffer_copy(response)
81+
self.ddb.observe_security_state(
82+
infuse_id,
83+
bytes(decoded.cloud_public_key),
84+
bytes(decoded.device_public_key),
85+
decoded.network_id,
86+
challenge,
87+
decoded.challenge_response_type,
88+
bytes(decoded.challenge_response),
89+
)
8590
if cb_event is not None:
8691
cb_event.set()
8792

@@ -91,7 +96,6 @@ def public_keys_done(pkt: PacketReceived, rc: int, response: bytes, _):
9196
decoded = defs.security_public_keys.response.vla_from_buffer_copy(response)
9297
for key in decoded.public_keys:
9398
if key.id == defs.rpc_enum_key_id.SECONDARY_REMOTE_PUBLIC_KEY:
94-
infuse_id = pkt.route[0].infuse_id
9599
self.ddb.observe_secondary_remote_public_key(infuse_id, bytes(key.key))
96100

97101
def run_cmd_pkt(cmd_pkt: PacketOutputRouted):
@@ -105,15 +109,15 @@ def run_cmd_pkt(cmd_pkt: PacketOutputRouted):
105109

106110
# Run security_state RPC
107111
challenge = random.randbytes(16)
108-
cmd_pkt = self.rpc.generate(
109-
defs.security_state.COMMAND_ID, challenge, Auth.NETWORK, security_state_done, challenge
112+
cmd_pkt = self.rpc.generate_addressed(
113+
infuse_id, defs.security_state.COMMAND_ID, challenge, Auth.NETWORK, security_state_done, challenge
110114
)
111115
run_cmd_pkt(cmd_pkt)
112116

113117
if self.ddb.has_local_root:
114118
# Query other public keys from the device
115-
cmd_pkt = self.rpc.generate(
116-
defs.security_public_keys.COMMAND_ID, b"\x00", Auth.NETWORK, public_keys_done, None
119+
cmd_pkt = self.rpc.generate_addressed(
120+
infuse_id, defs.security_public_keys.COMMAND_ID, b"\x00", Auth.NETWORK, public_keys_done, None
117121
)
118122
run_cmd_pkt(cmd_pkt)
119123

@@ -193,7 +197,7 @@ def _handle_serial_frame(self, frame: bytearray):
193197
else:
194198
Console.log_info(f"Dropping {len(frame)} byte packet...")
195199
else:
196-
self._common.query_device_key(None)
200+
self._common.query_device_key(self._common.ddb.gateway, None)
197201
Console.log_info(f"Dropping {len(frame)} byte packet to query device key...")
198202
return
199203
except cryptography.exceptions.InvalidTag as e:
@@ -213,7 +217,8 @@ def _handle_serial_frame(self, frame: bytearray):
213217
self._handle_memfault_pkt(pkt)
214218
# Proactively requery keys
215219
elif pkt.ptype == InfuseType.KEY_IDS:
216-
self._common.query_device_key(None)
220+
assert self._common.ddb.gateway is not None
221+
self._common.query_device_key(self._common.ddb.gateway, None)
217222

218223
# Forward to clients
219224
notification = ClientNotificationEpacketReceived(pkt)
@@ -264,9 +269,9 @@ def _handle_epacket_send(self, req: GatewayRequestEpacketSend):
264269

265270
# Do we have the device public keys we need?
266271
for hop in routed.route:
267-
if hop.auth == Auth.DEVICE and not self._common.ddb.has_public_key(hop.infuse_id):
272+
if hop.auth == Auth.DEVICE and not self._common.ddb.has_shared_key(hop.infuse_id):
268273
cb_event = threading.Event()
269-
self._common.query_device_key(cb_event)
274+
self._common.query_device_key(hop.infuse_id, cb_event)
270275

271276
# Encode and encrypt payload
272277
encrypted = routed.to_serial(self._common.ddb)
@@ -353,7 +358,7 @@ def _handle_conn_request(self, req: GatewayRequestConnectionRequest):
353358
subs,
354359
0,
355360
)
356-
cmd = self._common.rpc.generate(
361+
cmd = self._common.rpc.generate_serial(
357362
defs.bt_connect_infuse.COMMAND_ID, bytes(connect_args), Auth.DEVICE, self._bt_connect_cb, None
358363
)
359364
encrypted = cmd.to_serial(self._common.ddb)
@@ -379,7 +384,9 @@ def _handle_conn_release(self, req: GatewayRequestConnectionRelease):
379384
self._connected.pop(req.infuse_id)
380385

381386
disconnect_args = defs.bt_disconnect.request(state.bt_addr.to_rpc_struct())
382-
cmd = self._common.rpc.generate(defs.bt_disconnect.COMMAND_ID, bytes(disconnect_args), Auth.DEVICE, None, None)
387+
cmd = self._common.rpc.generate_serial(
388+
defs.bt_disconnect.COMMAND_ID, bytes(disconnect_args), Auth.DEVICE, None, None
389+
)
383390
encrypted = cmd.to_serial(self._common.ddb)
384391
Console.log_tx(cmd.ptype, len(encrypted))
385392
self._common.port.write(encrypted)

src/infuse_iot/tools/native_bt.py

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import asyncio
1010
import ctypes
1111
import json
12+
import random
1213
from typing import Any
1314

1415
from bleak import BleakClient, BleakScanner
@@ -17,6 +18,7 @@
1718
from bleak.backends.scanner import AdvertisementData
1819
from cryptography.exceptions import InvalidTag
1920

21+
import infuse_iot.definitions.rpc as defs
2022
from infuse_iot.commands import InfuseCommand
2123
from infuse_iot.common import InfuseBluetoothUUID, InfuseType
2224
from infuse_iot.database import DeviceDatabase, UnknownNetworkError
@@ -46,6 +48,7 @@
4648
)
4749
from infuse_iot.util.argparse import BtLeAddress, ValidFile, add_server_port_parser
4850
from infuse_iot.util.console import Console
51+
from infuse_iot.util.local_rpc_server import LocalRpcServer
4952

5053

5154
class InfuseGattReadResponse(ctypes.LittleEndianStructure):
@@ -66,6 +69,7 @@ def __init__(self, database: DeviceDatabase, server: LocalServer, bleak_mapping:
6669
self._mapping = bleak_mapping
6770
self._queues: dict[int, asyncio.Queue] = {}
6871
self._tasks: dict[int, asyncio.Task] = {}
72+
self._rpc = LocalRpcServer(database, native_bt=True)
6973

7074
def wrapped_broadcast(self, notifcation: ClientNotification):
7175
try:
@@ -98,35 +102,84 @@ def notification_handler(self, _characteristic: BleakGATTCharacteristic, data: b
98102
bytes(decr),
99103
)
100104
Console.log_rx(pkt.ptype, len(data))
105+
# Handle any local RPC responses
106+
self._rpc.handle(pkt)
107+
# Forward to clients
101108
self.wrapped_broadcast(ClientNotificationEpacketReceived(pkt))
102109

103110
async def create_connection_internal(
104111
self, request: GatewayRequestConnectionRequest, dev: BLEDevice, queue: asyncio.Queue
105112
):
106-
Console.log_info(f"{dev}: Initiating connection")
113+
command_notify_enabled = False
114+
Console.log_info(f"{request.infuse_id:016x}: Initiating connection")
107115
async with BleakClient(dev, timeout=request.timeout_ms / 1000) as client:
108116
# Modified from bleak example code
109117
if client._backend.__class__.__name__ == "BleakClientBlueZDBus":
110118
await client._backend._acquire_mtu() # type: ignore
111119

112-
security_info = await client.read_gatt_char(InfuseBluetoothUUID.COMMAND_CHAR)
113-
resp = InfuseGattReadResponse.from_buffer_copy(security_info)
114-
self._db.observe_security_state(
115-
request.infuse_id,
116-
bytes(resp.cloud_public_key),
117-
bytes(resp.device_public_key),
118-
resp.network_id,
119-
)
120+
Console.log_info(f"{request.infuse_id:016x}: Connected (MTU {client.mtu_size})")
121+
122+
have_shared_key = self._db.has_shared_key(request.infuse_id)
123+
if have_shared_key:
124+
# Read the current keys back to confirm they haven't changed
125+
security_info = await client.read_gatt_char(InfuseBluetoothUUID.COMMAND_CHAR)
126+
resp = InfuseGattReadResponse.from_buffer_copy(security_info)
127+
key_id = self._db.get_device_key_id(bytes(resp.cloud_public_key), bytes(resp.device_public_key))
128+
if self._db.devices[request.infuse_id].device_key_id != key_id:
129+
# Keys mismatch, invaidate the shared key
130+
Console.log_info(f"{dev}: Key mismatch, re-running derivation")
131+
have_shared_key = False
132+
133+
if not have_shared_key:
134+
# Always need the command characteristic to get the response
135+
await client.start_notify(InfuseBluetoothUUID.COMMAND_CHAR, self.notification_handler)
136+
command_notify_enabled = True
137+
138+
security_state_received = asyncio.Event()
139+
140+
def security_state_done(pkt: PacketReceived, _rc: int, response: bytes, challenge):
141+
decoded = defs.security_state.response.vla_from_buffer_copy(response)
142+
self._db.observe_security_state(
143+
request.infuse_id,
144+
bytes(decoded.cloud_public_key),
145+
bytes(decoded.device_public_key),
146+
decoded.network_id,
147+
challenge,
148+
decoded.challenge_response_type,
149+
bytes(decoded.challenge_response),
150+
)
151+
security_state_received.set()
152+
153+
# Construct the Security State RPC command
154+
challenge = random.randbytes(16)
155+
ss_pkt = self._rpc.generate_addressed(
156+
request.infuse_id,
157+
defs.security_state.COMMAND_ID,
158+
challenge,
159+
Auth.NETWORK,
160+
security_state_done,
161+
challenge,
162+
)
120163

121-
if request.data_types & request.DataType.COMMAND:
164+
# Encrypt command and write to remote
165+
encr = CtypeBtGattFrame.encrypt(self._db, request.infuse_id, ss_pkt.ptype, Auth.NETWORK, ss_pkt.payload)
166+
Console.log_tx(ss_pkt.ptype, len(encr))
167+
await client.write_gatt_char(InfuseBluetoothUUID.COMMAND_CHAR, encr, response=False)
168+
169+
# Wait for a response
170+
await asyncio.wait_for(security_state_received.wait(), timeout=request.timeout_ms / 1000)
171+
172+
# Disable the command characteristic if not requested
173+
if not (request.data_types & request.DataType.COMMAND):
174+
await client.stop_notify(InfuseBluetoothUUID.COMMAND_CHAR)
175+
176+
if (request.data_types & request.DataType.COMMAND) and not command_notify_enabled:
122177
await client.start_notify(InfuseBluetoothUUID.COMMAND_CHAR, self.notification_handler)
123178
if request.data_types & request.DataType.DATA:
124179
await client.start_notify(InfuseBluetoothUUID.DATA_CHAR, self.notification_handler)
125180
if request.data_types & request.DataType.LOGGING:
126181
await client.start_notify(InfuseBluetoothUUID.LOGGING_CHAR, self.notification_handler)
127182

128-
Console.log_info(f"{dev}: Connected (MTU {client.mtu_size})")
129-
130183
self.wrapped_broadcast(
131184
ClientNotificationConnectionCreated(
132185
request.infuse_id,

src/infuse_iot/util/local_rpc_server.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@
2626
class LocalRpcServer:
2727
"""Basic class supporting locally generated commands"""
2828

29-
def __init__(self, database: DeviceDatabase):
29+
def __init__(self, database: DeviceDatabase, native_bt: bool = False):
3030
self._cnt = random.randint(0, 2**31)
3131
self._ddb = database
32+
self._native_bt = native_bt
3233
self._queued: dict[int, tuple[RpcCallback | None, typing.Any]] = {}
3334

34-
def generate(
35+
def generate_serial(
3536
self, command: int, args: bytes, auth: Auth, cb: RpcCallback | None, cb_ctx: typing.Any
3637
) -> PacketOutputRouted:
3738
"""Generate RPC packet from arguments"""
@@ -47,6 +48,20 @@ def generate(
4748
self._cnt += 1
4849
return cmd_pkt
4950

51+
def generate_remote_bt_direct(
52+
self, remote: int, command: int, args: bytes, auth: Auth, cb: RpcCallback | None, cb_ctx: typing.Any
53+
) -> PacketOutputRouted:
54+
"""Generate RPC packet for Bluetooth remote from arguments, without intermediate hops"""
55+
cmd_bytes = bytes(rpc.RequestHeader(self._cnt, command)) + args
56+
cmd_pkt = PacketOutputRouted(
57+
[HopOutput(remote, interface.ID.BT_CENTRAL, auth)],
58+
InfuseType.RPC_CMD,
59+
cmd_bytes,
60+
)
61+
self._queued[self._cnt] = (cb, cb_ctx)
62+
self._cnt += 1
63+
return cmd_pkt
64+
5065
def generate_remote_bt(
5166
self, remote: int, command: int, args: bytes, auth: Auth, cb: RpcCallback | None, cb_ctx: typing.Any
5267
) -> PacketOutputRouted:
@@ -64,6 +79,16 @@ def generate_remote_bt(
6479
cmd_bytes,
6580
)
6681

82+
def generate_addressed(
83+
self, address: int, command: int, args: bytes, auth: Auth, cb: RpcCallback | None, cb_ctx: typing.Any
84+
) -> PacketOutputRouted:
85+
"""Generate RPC packet for explicit address"""
86+
if self._native_bt:
87+
return self.generate_remote_bt_direct(address, command, args, auth, cb, cb_ctx)
88+
if address == self._ddb.gateway:
89+
return self.generate_serial(command, args, auth, cb, cb_ctx)
90+
return self.generate_remote_bt(address, command, args, auth, cb, cb_ctx)
91+
6792
def handle(self, pkt: PacketReceived):
6893
"""Handle received packets"""
6994
# Only care about RPC responses

0 commit comments

Comments
 (0)