blerpc-protocol 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blerpc_protocol/__init__.py +62 -0
- blerpc_protocol/command.py +76 -0
- blerpc_protocol/container.py +387 -0
- blerpc_protocol/crypto.py +598 -0
- blerpc_protocol-0.8.0.dist-info/METADATA +102 -0
- blerpc_protocol-0.8.0.dist-info/RECORD +9 -0
- blerpc_protocol-0.8.0.dist-info/WHEEL +5 -0
- blerpc_protocol-0.8.0.dist-info/licenses/LICENSE +13 -0
- blerpc_protocol-0.8.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""blerpc-protocol: Container and command protocol layers for BLE RPC."""
|
|
2
|
+
|
|
3
|
+
from blerpc_protocol.command import CommandPacket, CommandType
|
|
4
|
+
from blerpc_protocol.container import (
|
|
5
|
+
ATT_OVERHEAD,
|
|
6
|
+
CAPABILITY_FLAG_ENCRYPTION_SUPPORTED,
|
|
7
|
+
CONTROL_HEADER_SIZE,
|
|
8
|
+
FIRST_HEADER_SIZE,
|
|
9
|
+
SUBSEQUENT_HEADER_SIZE,
|
|
10
|
+
Container,
|
|
11
|
+
ContainerAssembler,
|
|
12
|
+
ContainerSplitter,
|
|
13
|
+
ContainerType,
|
|
14
|
+
ControlCmd,
|
|
15
|
+
make_capabilities_request,
|
|
16
|
+
make_capabilities_response,
|
|
17
|
+
make_error_response,
|
|
18
|
+
make_key_exchange,
|
|
19
|
+
make_stream_end_c2p,
|
|
20
|
+
make_stream_end_p2c,
|
|
21
|
+
make_timeout_request,
|
|
22
|
+
make_timeout_response,
|
|
23
|
+
)
|
|
24
|
+
from blerpc_protocol.crypto import (
|
|
25
|
+
BlerpcCrypto,
|
|
26
|
+
BlerpcCryptoSession,
|
|
27
|
+
CentralKeyExchange,
|
|
28
|
+
KnownKeyStore,
|
|
29
|
+
PeripheralKeyExchange,
|
|
30
|
+
central_perform_key_exchange,
|
|
31
|
+
tofu_verify,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"ATT_OVERHEAD",
|
|
36
|
+
"BlerpcCrypto",
|
|
37
|
+
"BlerpcCryptoSession",
|
|
38
|
+
"CAPABILITY_FLAG_ENCRYPTION_SUPPORTED",
|
|
39
|
+
"CentralKeyExchange",
|
|
40
|
+
"CONTROL_HEADER_SIZE",
|
|
41
|
+
"FIRST_HEADER_SIZE",
|
|
42
|
+
"SUBSEQUENT_HEADER_SIZE",
|
|
43
|
+
"CommandPacket",
|
|
44
|
+
"CommandType",
|
|
45
|
+
"Container",
|
|
46
|
+
"ContainerAssembler",
|
|
47
|
+
"ContainerSplitter",
|
|
48
|
+
"ContainerType",
|
|
49
|
+
"ControlCmd",
|
|
50
|
+
"make_capabilities_request",
|
|
51
|
+
"make_capabilities_response",
|
|
52
|
+
"make_error_response",
|
|
53
|
+
"make_key_exchange",
|
|
54
|
+
"make_stream_end_c2p",
|
|
55
|
+
"make_stream_end_p2c",
|
|
56
|
+
"make_timeout_request",
|
|
57
|
+
"make_timeout_response",
|
|
58
|
+
"PeripheralKeyExchange",
|
|
59
|
+
"KnownKeyStore",
|
|
60
|
+
"central_perform_key_exchange",
|
|
61
|
+
"tofu_verify",
|
|
62
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Command encode/decode layer for blerpc.
|
|
2
|
+
|
|
3
|
+
Command format (bits):
|
|
4
|
+
| type(1) | reserved(7) | cmd_name_len(8) | cmd_name(N*8) |
|
|
5
|
+
| data_len(16) | data(data_len*8) |
|
|
6
|
+
|
|
7
|
+
- type: 0=request, 1=response
|
|
8
|
+
- cmd_name: ASCII command name
|
|
9
|
+
- data_len: little-endian uint16
|
|
10
|
+
- data: protobuf-encoded bytes
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import struct
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import IntEnum
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CommandType(IntEnum):
|
|
21
|
+
REQUEST = 0
|
|
22
|
+
RESPONSE = 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class CommandPacket:
|
|
27
|
+
"""A single command packet."""
|
|
28
|
+
|
|
29
|
+
cmd_type: CommandType
|
|
30
|
+
cmd_name: str
|
|
31
|
+
data: bytes = b""
|
|
32
|
+
|
|
33
|
+
def serialize(self) -> bytes:
|
|
34
|
+
"""Serialize command to bytes."""
|
|
35
|
+
name_bytes = self.cmd_name.encode("ascii")
|
|
36
|
+
if len(name_bytes) > 255:
|
|
37
|
+
raise ValueError(f"cmd_name too long: {len(name_bytes)} > 255")
|
|
38
|
+
if len(self.data) > 65535:
|
|
39
|
+
raise ValueError(f"data too long: {len(self.data)} > 65535")
|
|
40
|
+
|
|
41
|
+
# Byte 0: type in MSB (bit 7), reserved bits 6-0 = 0
|
|
42
|
+
byte0 = (self.cmd_type & 0x01) << 7
|
|
43
|
+
return (
|
|
44
|
+
bytes([byte0])
|
|
45
|
+
+ struct.pack("<B", len(name_bytes))
|
|
46
|
+
+ name_bytes
|
|
47
|
+
+ struct.pack("<H", len(self.data))
|
|
48
|
+
+ self.data
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def deserialize(data: bytes) -> CommandPacket:
|
|
53
|
+
"""Deserialize bytes into a CommandPacket."""
|
|
54
|
+
if len(data) < 2:
|
|
55
|
+
raise ValueError(f"Command packet too short: {len(data)} bytes")
|
|
56
|
+
|
|
57
|
+
# Byte 0: type in MSB
|
|
58
|
+
cmd_type = CommandType((data[0] >> 7) & 0x01)
|
|
59
|
+
cmd_name_len = data[1]
|
|
60
|
+
|
|
61
|
+
offset = 2
|
|
62
|
+
if len(data) < offset + cmd_name_len + 2:
|
|
63
|
+
raise ValueError("Command packet truncated")
|
|
64
|
+
|
|
65
|
+
cmd_name = data[offset : offset + cmd_name_len].decode("ascii")
|
|
66
|
+
offset += cmd_name_len
|
|
67
|
+
|
|
68
|
+
data_len = struct.unpack_from("<H", data, offset)[0]
|
|
69
|
+
offset += 2
|
|
70
|
+
|
|
71
|
+
payload = data[offset : offset + data_len]
|
|
72
|
+
return CommandPacket(
|
|
73
|
+
cmd_type=cmd_type,
|
|
74
|
+
cmd_name=cmd_name,
|
|
75
|
+
data=payload,
|
|
76
|
+
)
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Container split/merge/control layer for blerpc.
|
|
2
|
+
|
|
3
|
+
Container format (bits):
|
|
4
|
+
| transaction_id(8) | sequence_number(8) | type(2)|control_cmd(4)|reserved(2) |
|
|
5
|
+
| total_length(16 or 0) | payload_len(8) | payload(variable) |
|
|
6
|
+
|
|
7
|
+
type=0b00 (FIRST): has total_length, header = 6 bytes
|
|
8
|
+
type=0b01 (SUBSEQUENT): no total_length, header = 4 bytes
|
|
9
|
+
type=0b11 (CONTROL): no total_length, header = 4 bytes
|
|
10
|
+
|
|
11
|
+
All multi-byte fields are little-endian.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import struct
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import IntEnum
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ContainerType(IntEnum):
|
|
22
|
+
FIRST = 0b00
|
|
23
|
+
SUBSEQUENT = 0b01
|
|
24
|
+
CONTROL = 0b11
|
|
25
|
+
# Sentinel for the one undefined 2-bit type value (0b10). Never emitted by
|
|
26
|
+
# senders; produced when parsing an unrecognized frame so a hostile peer
|
|
27
|
+
# cannot crash the parser with an out-of-range type. Treated as droppable
|
|
28
|
+
# by the assembler and control handlers.
|
|
29
|
+
UNKNOWN = 0b10
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ControlCmd(IntEnum):
|
|
33
|
+
NONE = 0x0
|
|
34
|
+
TIMEOUT = 0x1
|
|
35
|
+
STREAM_END_C2P = 0x2
|
|
36
|
+
STREAM_END_P2C = 0x3
|
|
37
|
+
CAPABILITIES = 0x4
|
|
38
|
+
ERROR = 0x5
|
|
39
|
+
KEY_EXCHANGE = 0x6
|
|
40
|
+
# Sentinel for any unrecognized control command (0x7-0xF). Never emitted by
|
|
41
|
+
# senders; unknown commands parse to this value and are dropped by handlers
|
|
42
|
+
# rather than raising.
|
|
43
|
+
UNKNOWN = 0xF
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def _missing_(cls, value: object) -> "ControlCmd":
|
|
47
|
+
# A 4-bit wire field can carry values with no defined member; map them
|
|
48
|
+
# to UNKNOWN instead of raising ValueError on attacker-controlled input.
|
|
49
|
+
return cls.UNKNOWN
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Error codes for ControlCmd.ERROR
|
|
53
|
+
BLERPC_ERROR_RESPONSE_TOO_LARGE = 0x01
|
|
54
|
+
BLERPC_ERROR_BUSY = 0x02
|
|
55
|
+
|
|
56
|
+
# Capabilities flags (bit field)
|
|
57
|
+
CAPABILITY_FLAG_ENCRYPTION_SUPPORTED = 0x0001
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Header sizes
|
|
61
|
+
FIRST_HEADER_SIZE = 6 # txn_id(1) + seq(1) + flags(1) + total_len(2) + payload_len(1)
|
|
62
|
+
SUBSEQUENT_HEADER_SIZE = 4 # txn_id(1) + seq(1) + flags(1) + payload_len(1)
|
|
63
|
+
CONTROL_HEADER_SIZE = 4 # txn_id(1) + seq(1) + flags(1) + payload_len(1)
|
|
64
|
+
|
|
65
|
+
ATT_OVERHEAD = 3 # ATT header bytes subtracted from MTU
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _pack_flags(
|
|
69
|
+
container_type: ContainerType, control_cmd: ControlCmd = ControlCmd.NONE
|
|
70
|
+
) -> int:
|
|
71
|
+
"""Pack type(2) | control_cmd(4) | reserved(2) into a single byte."""
|
|
72
|
+
return ((container_type & 0x03) << 6) | ((control_cmd & 0x0F) << 2)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _unpack_flags(flags_byte: int) -> tuple[ContainerType, ControlCmd]:
|
|
76
|
+
"""Unpack flags byte into (type, control_cmd)."""
|
|
77
|
+
container_type = ContainerType((flags_byte >> 6) & 0x03)
|
|
78
|
+
control_cmd = ControlCmd((flags_byte >> 2) & 0x0F)
|
|
79
|
+
return container_type, control_cmd
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Container:
|
|
84
|
+
"""A single container packet."""
|
|
85
|
+
|
|
86
|
+
transaction_id: int
|
|
87
|
+
sequence_number: int
|
|
88
|
+
container_type: ContainerType
|
|
89
|
+
control_cmd: ControlCmd = ControlCmd.NONE
|
|
90
|
+
total_length: int = 0 # Only meaningful for FIRST
|
|
91
|
+
payload: bytes = b""
|
|
92
|
+
|
|
93
|
+
def serialize(self) -> bytes:
|
|
94
|
+
"""Serialize container to bytes."""
|
|
95
|
+
flags = _pack_flags(self.container_type, self.control_cmd)
|
|
96
|
+
|
|
97
|
+
if self.container_type == ContainerType.FIRST:
|
|
98
|
+
header = struct.pack(
|
|
99
|
+
"<BBBHB",
|
|
100
|
+
self.transaction_id,
|
|
101
|
+
self.sequence_number,
|
|
102
|
+
flags,
|
|
103
|
+
self.total_length,
|
|
104
|
+
len(self.payload),
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
header = struct.pack(
|
|
108
|
+
"<BBBB",
|
|
109
|
+
self.transaction_id,
|
|
110
|
+
self.sequence_number,
|
|
111
|
+
flags,
|
|
112
|
+
len(self.payload),
|
|
113
|
+
)
|
|
114
|
+
return header + self.payload
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def deserialize(data: bytes) -> Container:
|
|
118
|
+
"""Deserialize bytes into a Container."""
|
|
119
|
+
if len(data) < 4:
|
|
120
|
+
raise ValueError(f"Container too short: {len(data)} bytes")
|
|
121
|
+
|
|
122
|
+
transaction_id = data[0]
|
|
123
|
+
sequence_number = data[1]
|
|
124
|
+
container_type, control_cmd = _unpack_flags(data[2])
|
|
125
|
+
|
|
126
|
+
if container_type == ContainerType.FIRST:
|
|
127
|
+
if len(data) < FIRST_HEADER_SIZE:
|
|
128
|
+
raise ValueError(f"FIRST container too short: {len(data)} bytes")
|
|
129
|
+
total_length = struct.unpack_from("<H", data, 3)[0]
|
|
130
|
+
payload_len = data[5]
|
|
131
|
+
payload = data[FIRST_HEADER_SIZE : FIRST_HEADER_SIZE + payload_len]
|
|
132
|
+
else:
|
|
133
|
+
total_length = 0
|
|
134
|
+
payload_len = data[3]
|
|
135
|
+
header_size = SUBSEQUENT_HEADER_SIZE
|
|
136
|
+
payload = data[header_size : header_size + payload_len]
|
|
137
|
+
|
|
138
|
+
return Container(
|
|
139
|
+
transaction_id=transaction_id,
|
|
140
|
+
sequence_number=sequence_number,
|
|
141
|
+
container_type=container_type,
|
|
142
|
+
control_cmd=control_cmd,
|
|
143
|
+
total_length=total_length,
|
|
144
|
+
payload=payload,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ContainerSplitter:
|
|
149
|
+
"""Splits a payload into containers respecting MTU."""
|
|
150
|
+
|
|
151
|
+
def __init__(self, mtu: int = 247):
|
|
152
|
+
self._mtu = mtu
|
|
153
|
+
self._transaction_counter = 0
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def effective_mtu(self) -> int:
|
|
157
|
+
"""Usable bytes per BLE packet (MTU - ATT overhead)."""
|
|
158
|
+
return self._mtu - ATT_OVERHEAD
|
|
159
|
+
|
|
160
|
+
def next_transaction_id(self) -> int:
|
|
161
|
+
tid = self._transaction_counter
|
|
162
|
+
self._transaction_counter = (self._transaction_counter + 1) & 0xFF
|
|
163
|
+
return tid
|
|
164
|
+
|
|
165
|
+
def split(
|
|
166
|
+
self, payload: bytes, transaction_id: int | None = None
|
|
167
|
+
) -> list[Container]:
|
|
168
|
+
"""Split payload into a list of containers.
|
|
169
|
+
|
|
170
|
+
Raises ValueError if payload is too large for 8-bit
|
|
171
|
+
sequence_number (>255 containers).
|
|
172
|
+
"""
|
|
173
|
+
if transaction_id is None:
|
|
174
|
+
transaction_id = self.next_transaction_id()
|
|
175
|
+
|
|
176
|
+
total_length = len(payload)
|
|
177
|
+
if total_length > 65535:
|
|
178
|
+
raise ValueError(f"Payload too large: {total_length} > 65535")
|
|
179
|
+
containers: list[Container] = []
|
|
180
|
+
|
|
181
|
+
# First container
|
|
182
|
+
first_max_payload = self.effective_mtu - FIRST_HEADER_SIZE
|
|
183
|
+
first_payload = payload[:first_max_payload]
|
|
184
|
+
containers.append(
|
|
185
|
+
Container(
|
|
186
|
+
transaction_id=transaction_id,
|
|
187
|
+
sequence_number=0,
|
|
188
|
+
container_type=ContainerType.FIRST,
|
|
189
|
+
total_length=total_length,
|
|
190
|
+
payload=first_payload,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
offset = len(first_payload)
|
|
195
|
+
seq = 1
|
|
196
|
+
|
|
197
|
+
# Subsequent containers
|
|
198
|
+
subsequent_max_payload = self.effective_mtu - SUBSEQUENT_HEADER_SIZE
|
|
199
|
+
while offset < total_length:
|
|
200
|
+
if seq > 255:
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"Payload requires more than 256 containers (seq={seq}), "
|
|
203
|
+
"exceeding 8-bit sequence_number limit"
|
|
204
|
+
)
|
|
205
|
+
chunk = payload[offset : offset + subsequent_max_payload]
|
|
206
|
+
containers.append(
|
|
207
|
+
Container(
|
|
208
|
+
transaction_id=transaction_id,
|
|
209
|
+
sequence_number=seq,
|
|
210
|
+
container_type=ContainerType.SUBSEQUENT,
|
|
211
|
+
payload=chunk,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
offset += len(chunk)
|
|
215
|
+
seq += 1
|
|
216
|
+
|
|
217
|
+
return containers
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class ContainerAssembler:
|
|
221
|
+
"""Reassembles containers into a complete payload."""
|
|
222
|
+
|
|
223
|
+
def __init__(self):
|
|
224
|
+
self._transactions: dict[int, _AssemblyState] = {}
|
|
225
|
+
|
|
226
|
+
def feed(self, container: Container) -> bytes | None:
|
|
227
|
+
"""Feed a container. Returns complete payload when done, else None."""
|
|
228
|
+
if container.container_type == ContainerType.CONTROL:
|
|
229
|
+
return None # Control containers are handled separately
|
|
230
|
+
|
|
231
|
+
tid = container.transaction_id
|
|
232
|
+
|
|
233
|
+
if container.container_type == ContainerType.FIRST:
|
|
234
|
+
self._transactions[tid] = _AssemblyState(
|
|
235
|
+
total_length=container.total_length,
|
|
236
|
+
expected_seq=1,
|
|
237
|
+
fragments=[container.payload],
|
|
238
|
+
received_length=len(container.payload),
|
|
239
|
+
)
|
|
240
|
+
elif tid in self._transactions:
|
|
241
|
+
state = self._transactions[tid]
|
|
242
|
+
if container.sequence_number != state.expected_seq:
|
|
243
|
+
# Sequence gap — discard entire transaction
|
|
244
|
+
del self._transactions[tid]
|
|
245
|
+
return None
|
|
246
|
+
state.fragments.append(container.payload)
|
|
247
|
+
state.received_length += len(container.payload)
|
|
248
|
+
state.expected_seq += 1
|
|
249
|
+
else:
|
|
250
|
+
# Subsequent without a FIRST — ignore
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
state = self._transactions[tid]
|
|
254
|
+
if state.received_length >= state.total_length:
|
|
255
|
+
payload = b"".join(state.fragments)[: state.total_length]
|
|
256
|
+
del self._transactions[tid]
|
|
257
|
+
return payload
|
|
258
|
+
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
def reset(self):
|
|
262
|
+
"""Clear all pending assembly state."""
|
|
263
|
+
self._transactions.clear()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@dataclass
|
|
267
|
+
class _AssemblyState:
|
|
268
|
+
total_length: int
|
|
269
|
+
expected_seq: int
|
|
270
|
+
fragments: list[bytes] = field(default_factory=list)
|
|
271
|
+
received_length: int = 0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def make_timeout_request(transaction_id: int, sequence_number: int = 0) -> Container:
|
|
275
|
+
"""Create a timeout request control container (Central -> Peripheral)."""
|
|
276
|
+
return Container(
|
|
277
|
+
transaction_id=transaction_id,
|
|
278
|
+
sequence_number=sequence_number,
|
|
279
|
+
container_type=ContainerType.CONTROL,
|
|
280
|
+
control_cmd=ControlCmd.TIMEOUT,
|
|
281
|
+
payload=b"",
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def make_timeout_response(
|
|
286
|
+
transaction_id: int, timeout_ms: int, sequence_number: int = 0
|
|
287
|
+
) -> Container:
|
|
288
|
+
"""Create a timeout response control container (Peripheral -> Central)."""
|
|
289
|
+
return Container(
|
|
290
|
+
transaction_id=transaction_id,
|
|
291
|
+
sequence_number=sequence_number,
|
|
292
|
+
container_type=ContainerType.CONTROL,
|
|
293
|
+
control_cmd=ControlCmd.TIMEOUT,
|
|
294
|
+
payload=struct.pack("<H", timeout_ms),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def make_stream_end_c2p(transaction_id: int, sequence_number: int = 0) -> Container:
|
|
299
|
+
"""Create stream end container (Central -> Peripheral)."""
|
|
300
|
+
return Container(
|
|
301
|
+
transaction_id=transaction_id,
|
|
302
|
+
sequence_number=sequence_number,
|
|
303
|
+
container_type=ContainerType.CONTROL,
|
|
304
|
+
control_cmd=ControlCmd.STREAM_END_C2P,
|
|
305
|
+
payload=b"",
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def make_stream_end_p2c(transaction_id: int, sequence_number: int = 0) -> Container:
|
|
310
|
+
"""Create stream end container (Peripheral -> Central)."""
|
|
311
|
+
return Container(
|
|
312
|
+
transaction_id=transaction_id,
|
|
313
|
+
sequence_number=sequence_number,
|
|
314
|
+
container_type=ContainerType.CONTROL,
|
|
315
|
+
control_cmd=ControlCmd.STREAM_END_P2C,
|
|
316
|
+
payload=b"",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def make_capabilities_request(
|
|
321
|
+
transaction_id: int,
|
|
322
|
+
max_request_payload_size: int = 0,
|
|
323
|
+
max_response_payload_size: int = 0,
|
|
324
|
+
flags: int = 0,
|
|
325
|
+
sequence_number: int = 0,
|
|
326
|
+
) -> Container:
|
|
327
|
+
"""Create a capabilities request control container (Central -> Peripheral).
|
|
328
|
+
|
|
329
|
+
6-byte payload: [max_req:u16LE][max_resp:u16LE][flags:u16LE]
|
|
330
|
+
"""
|
|
331
|
+
return Container(
|
|
332
|
+
transaction_id=transaction_id,
|
|
333
|
+
sequence_number=sequence_number,
|
|
334
|
+
container_type=ContainerType.CONTROL,
|
|
335
|
+
control_cmd=ControlCmd.CAPABILITIES,
|
|
336
|
+
payload=struct.pack(
|
|
337
|
+
"<HHH", max_request_payload_size, max_response_payload_size, flags
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def make_capabilities_response(
|
|
343
|
+
transaction_id: int,
|
|
344
|
+
max_request_payload_size: int,
|
|
345
|
+
max_response_payload_size: int,
|
|
346
|
+
flags: int = 0,
|
|
347
|
+
sequence_number: int = 0,
|
|
348
|
+
) -> Container:
|
|
349
|
+
"""Create a capabilities response control container (Peripheral -> Central).
|
|
350
|
+
|
|
351
|
+
6-byte payload: [max_req:u16LE][max_resp:u16LE][flags:u16LE]
|
|
352
|
+
"""
|
|
353
|
+
return Container(
|
|
354
|
+
transaction_id=transaction_id,
|
|
355
|
+
sequence_number=sequence_number,
|
|
356
|
+
container_type=ContainerType.CONTROL,
|
|
357
|
+
control_cmd=ControlCmd.CAPABILITIES,
|
|
358
|
+
payload=struct.pack(
|
|
359
|
+
"<HHH", max_request_payload_size, max_response_payload_size, flags
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def make_error_response(
|
|
365
|
+
transaction_id: int, error_code: int, sequence_number: int = 0
|
|
366
|
+
) -> Container:
|
|
367
|
+
"""Create an error control container (Peripheral -> Central)."""
|
|
368
|
+
return Container(
|
|
369
|
+
transaction_id=transaction_id,
|
|
370
|
+
sequence_number=sequence_number,
|
|
371
|
+
container_type=ContainerType.CONTROL,
|
|
372
|
+
control_cmd=ControlCmd.ERROR,
|
|
373
|
+
payload=bytes([error_code]),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def make_key_exchange(
|
|
378
|
+
transaction_id: int, payload: bytes, sequence_number: int = 0
|
|
379
|
+
) -> Container:
|
|
380
|
+
"""Create a key exchange control container."""
|
|
381
|
+
return Container(
|
|
382
|
+
transaction_id=transaction_id,
|
|
383
|
+
sequence_number=sequence_number,
|
|
384
|
+
container_type=ContainerType.CONTROL,
|
|
385
|
+
control_cmd=ControlCmd.KEY_EXCHANGE,
|
|
386
|
+
payload=payload,
|
|
387
|
+
)
|
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"""E2E encryption for blerpc using X25519, Ed25519, AES-128-GCM, HKDF-SHA256."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import struct
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from cryptography.exceptions import InvalidSignature
|
|
12
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
14
|
+
Ed25519PrivateKey,
|
|
15
|
+
Ed25519PublicKey,
|
|
16
|
+
)
|
|
17
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import (
|
|
18
|
+
X25519PrivateKey,
|
|
19
|
+
X25519PublicKey,
|
|
20
|
+
)
|
|
21
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
22
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
23
|
+
|
|
24
|
+
# Direction bytes for nonce construction
|
|
25
|
+
DIRECTION_C2P = 0x00
|
|
26
|
+
DIRECTION_P2C = 0x01
|
|
27
|
+
|
|
28
|
+
# Confirmation plaintexts
|
|
29
|
+
CONFIRM_CENTRAL = b"BLERPC_CONFIRM_C"
|
|
30
|
+
CONFIRM_PERIPHERAL = b"BLERPC_CONFIRM_P"
|
|
31
|
+
|
|
32
|
+
# Key exchange step constants
|
|
33
|
+
KEY_EXCHANGE_STEP1 = 0x01
|
|
34
|
+
KEY_EXCHANGE_STEP2 = 0x02
|
|
35
|
+
KEY_EXCHANGE_STEP3 = 0x03
|
|
36
|
+
KEY_EXCHANGE_STEP4 = 0x04
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BlerpcCrypto:
|
|
40
|
+
"""Cryptographic operations for blerpc E2E encryption."""
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def generate_x25519_keypair() -> tuple[X25519PrivateKey, bytes]:
|
|
44
|
+
"""Generate an X25519 key pair.
|
|
45
|
+
|
|
46
|
+
Returns (private_key, public_key_bytes_32).
|
|
47
|
+
"""
|
|
48
|
+
private_key = X25519PrivateKey.generate()
|
|
49
|
+
public_bytes = private_key.public_key().public_bytes(
|
|
50
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
51
|
+
)
|
|
52
|
+
return private_key, public_bytes
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def x25519_public_bytes(private_key: X25519PrivateKey) -> bytes:
|
|
56
|
+
"""Get the raw 32-byte public key from a private key."""
|
|
57
|
+
return private_key.public_key().public_bytes(
|
|
58
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def x25519_shared_secret(
|
|
63
|
+
private_key: X25519PrivateKey, peer_public_bytes: bytes
|
|
64
|
+
) -> bytes:
|
|
65
|
+
"""Compute X25519 shared secret (32 bytes)."""
|
|
66
|
+
peer_public = X25519PublicKey.from_public_bytes(peer_public_bytes)
|
|
67
|
+
return private_key.exchange(peer_public)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def derive_session_key(
|
|
71
|
+
shared_secret: bytes,
|
|
72
|
+
central_pubkey: bytes,
|
|
73
|
+
peripheral_pubkey: bytes,
|
|
74
|
+
) -> bytes:
|
|
75
|
+
"""Derive 16-byte AES-128 session key using HKDF-SHA256.
|
|
76
|
+
|
|
77
|
+
salt = central_pubkey || peripheral_pubkey (64 bytes)
|
|
78
|
+
info = b"blerpc-session-key"
|
|
79
|
+
"""
|
|
80
|
+
hkdf = HKDF(
|
|
81
|
+
algorithm=hashes.SHA256(),
|
|
82
|
+
length=16,
|
|
83
|
+
salt=central_pubkey + peripheral_pubkey,
|
|
84
|
+
info=b"blerpc-session-key",
|
|
85
|
+
)
|
|
86
|
+
return hkdf.derive(shared_secret)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def generate_ed25519_keypair() -> tuple[Ed25519PrivateKey, bytes]:
|
|
90
|
+
"""Generate an Ed25519 key pair.
|
|
91
|
+
|
|
92
|
+
Returns (private_key, public_key_bytes_32).
|
|
93
|
+
"""
|
|
94
|
+
private_key = Ed25519PrivateKey.generate()
|
|
95
|
+
public_bytes = private_key.public_key().public_bytes(
|
|
96
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
97
|
+
)
|
|
98
|
+
return private_key, public_bytes
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def ed25519_public_bytes(private_key: Ed25519PrivateKey) -> bytes:
|
|
102
|
+
"""Get the raw 32-byte public key from a private key."""
|
|
103
|
+
return private_key.public_key().public_bytes(
|
|
104
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def ed25519_sign(private_key: Ed25519PrivateKey, message: bytes) -> bytes:
|
|
109
|
+
"""Sign a message with Ed25519. Returns 64-byte signature."""
|
|
110
|
+
return private_key.sign(message)
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def ed25519_verify(
|
|
114
|
+
public_key_bytes: bytes, message: bytes, signature: bytes
|
|
115
|
+
) -> bool:
|
|
116
|
+
"""Verify an Ed25519 signature. Returns True if valid."""
|
|
117
|
+
try:
|
|
118
|
+
public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes)
|
|
119
|
+
public_key.verify(signature, message)
|
|
120
|
+
return True
|
|
121
|
+
except (ValueError, InvalidSignature):
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def ed25519_private_from_bytes(data: bytes) -> Ed25519PrivateKey:
|
|
126
|
+
"""Load Ed25519 private key from raw 32-byte seed."""
|
|
127
|
+
return Ed25519PrivateKey.from_private_bytes(data)
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def x25519_private_from_bytes(data: bytes) -> X25519PrivateKey:
|
|
131
|
+
"""Load X25519 private key from raw 32 bytes."""
|
|
132
|
+
return X25519PrivateKey.from_private_bytes(data)
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _build_nonce(counter: int, direction: int) -> bytes:
|
|
136
|
+
"""Build 12-byte AES-GCM nonce: counter(4B) || direction(1B) || zeros(7B)."""
|
|
137
|
+
return struct.pack("<IB", counter, direction) + b"\x00" * 7
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def encrypt_command(
|
|
141
|
+
session_key: bytes, counter: int, direction: int, plaintext: bytes
|
|
142
|
+
) -> bytes:
|
|
143
|
+
"""Encrypt a command payload.
|
|
144
|
+
|
|
145
|
+
Returns: [counter:4BLE][ciphertext:NB][tag:16B]
|
|
146
|
+
"""
|
|
147
|
+
nonce = BlerpcCrypto._build_nonce(counter, direction)
|
|
148
|
+
aesgcm = AESGCM(session_key)
|
|
149
|
+
ct_and_tag = aesgcm.encrypt(nonce, plaintext, None)
|
|
150
|
+
return struct.pack("<I", counter) + ct_and_tag
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def decrypt_command(
|
|
154
|
+
session_key: bytes, direction: int, data: bytes
|
|
155
|
+
) -> tuple[int, bytes]:
|
|
156
|
+
"""Decrypt a command payload.
|
|
157
|
+
|
|
158
|
+
Input: [counter:4BLE][ciphertext:NB][tag:16B]
|
|
159
|
+
Returns: (counter, plaintext)
|
|
160
|
+
"""
|
|
161
|
+
if len(data) < 20:
|
|
162
|
+
raise ValueError(f"Encrypted payload too short: {len(data)}")
|
|
163
|
+
counter = struct.unpack_from("<I", data, 0)[0]
|
|
164
|
+
ct_and_tag = data[4:]
|
|
165
|
+
nonce = BlerpcCrypto._build_nonce(counter, direction)
|
|
166
|
+
aesgcm = AESGCM(session_key)
|
|
167
|
+
plaintext = aesgcm.decrypt(nonce, ct_and_tag, None)
|
|
168
|
+
return counter, plaintext
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def encrypt_confirmation(session_key: bytes, message: bytes) -> bytes:
|
|
172
|
+
"""Encrypt a confirmation message for key exchange step 3/4.
|
|
173
|
+
|
|
174
|
+
Returns: [nonce:12B][ciphertext:16B][tag:16B] = 44 bytes
|
|
175
|
+
"""
|
|
176
|
+
nonce = os.urandom(12)
|
|
177
|
+
aesgcm = AESGCM(session_key)
|
|
178
|
+
ct_and_tag = aesgcm.encrypt(nonce, message, None)
|
|
179
|
+
return nonce + ct_and_tag
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def decrypt_confirmation(session_key: bytes, data: bytes) -> bytes:
|
|
183
|
+
"""Decrypt a confirmation message from key exchange step 3/4.
|
|
184
|
+
|
|
185
|
+
Input: [nonce:12B][ciphertext:16B][tag:16B] = 44 bytes
|
|
186
|
+
Returns: plaintext (16 bytes)
|
|
187
|
+
"""
|
|
188
|
+
if len(data) < 44:
|
|
189
|
+
raise ValueError(f"Confirmation too short: {len(data)}")
|
|
190
|
+
nonce = data[:12]
|
|
191
|
+
ct_and_tag = data[12:]
|
|
192
|
+
aesgcm = AESGCM(session_key)
|
|
193
|
+
return aesgcm.decrypt(nonce, ct_and_tag, None)
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def build_step1_payload(central_x25519_pubkey: bytes) -> bytes:
|
|
197
|
+
"""Build KEY_EXCHANGE step 1 payload (33 bytes).
|
|
198
|
+
|
|
199
|
+
[step:u8=0x01][central_x25519_pubkey:32B]
|
|
200
|
+
"""
|
|
201
|
+
return bytes([KEY_EXCHANGE_STEP1]) + central_x25519_pubkey
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def parse_step1_payload(data: bytes) -> bytes:
|
|
205
|
+
"""Parse KEY_EXCHANGE step 1 payload.
|
|
206
|
+
|
|
207
|
+
Returns central_x25519_pubkey (32 bytes).
|
|
208
|
+
"""
|
|
209
|
+
if len(data) < 33 or data[0] != KEY_EXCHANGE_STEP1:
|
|
210
|
+
raise ValueError("Invalid step 1 payload")
|
|
211
|
+
return data[1:33]
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def build_step2_payload(
|
|
215
|
+
peripheral_x25519_pubkey: bytes,
|
|
216
|
+
ed25519_signature: bytes,
|
|
217
|
+
peripheral_ed25519_pubkey: bytes,
|
|
218
|
+
) -> bytes:
|
|
219
|
+
"""Build KEY_EXCHANGE step 2 payload (129 bytes).
|
|
220
|
+
|
|
221
|
+
[step:u8=0x02][peripheral_x25519_pubkey:32B][ed25519_signature:64B]
|
|
222
|
+
[peripheral_ed25519_pubkey:32B]
|
|
223
|
+
"""
|
|
224
|
+
return (
|
|
225
|
+
bytes([KEY_EXCHANGE_STEP2])
|
|
226
|
+
+ peripheral_x25519_pubkey
|
|
227
|
+
+ ed25519_signature
|
|
228
|
+
+ peripheral_ed25519_pubkey
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def parse_step2_payload(
|
|
233
|
+
data: bytes,
|
|
234
|
+
) -> tuple[bytes, bytes, bytes]:
|
|
235
|
+
"""Parse KEY_EXCHANGE step 2 payload.
|
|
236
|
+
|
|
237
|
+
Returns (peripheral_x25519_pubkey, ed25519_signature,
|
|
238
|
+
peripheral_ed25519_pubkey).
|
|
239
|
+
"""
|
|
240
|
+
if len(data) < 129 or data[0] != KEY_EXCHANGE_STEP2:
|
|
241
|
+
raise ValueError("Invalid step 2 payload")
|
|
242
|
+
peripheral_x25519_pubkey = data[1:33]
|
|
243
|
+
ed25519_signature = data[33:97]
|
|
244
|
+
peripheral_ed25519_pubkey = data[97:129]
|
|
245
|
+
return peripheral_x25519_pubkey, ed25519_signature, peripheral_ed25519_pubkey
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def build_step3_payload(confirmation_encrypted: bytes) -> bytes:
|
|
249
|
+
"""Build KEY_EXCHANGE step 3 payload (45 bytes).
|
|
250
|
+
|
|
251
|
+
[step:u8=0x03][nonce:12B][ciphertext:16B][tag:16B]
|
|
252
|
+
"""
|
|
253
|
+
return bytes([KEY_EXCHANGE_STEP3]) + confirmation_encrypted
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
def parse_step3_payload(data: bytes) -> bytes:
|
|
257
|
+
"""Parse KEY_EXCHANGE step 3 payload.
|
|
258
|
+
|
|
259
|
+
Returns the encrypted confirmation (44 bytes).
|
|
260
|
+
"""
|
|
261
|
+
if len(data) < 45 or data[0] != KEY_EXCHANGE_STEP3:
|
|
262
|
+
raise ValueError("Invalid step 3 payload")
|
|
263
|
+
return data[1:45]
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def build_step4_payload(confirmation_encrypted: bytes) -> bytes:
|
|
267
|
+
"""Build KEY_EXCHANGE step 4 payload (45 bytes).
|
|
268
|
+
|
|
269
|
+
[step:u8=0x04][nonce:12B][ciphertext:16B][tag:16B]
|
|
270
|
+
"""
|
|
271
|
+
return bytes([KEY_EXCHANGE_STEP4]) + confirmation_encrypted
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def parse_step4_payload(data: bytes) -> bytes:
|
|
275
|
+
"""Parse KEY_EXCHANGE step 4 payload.
|
|
276
|
+
|
|
277
|
+
Returns the encrypted confirmation (44 bytes).
|
|
278
|
+
"""
|
|
279
|
+
if len(data) < 45 or data[0] != KEY_EXCHANGE_STEP4:
|
|
280
|
+
raise ValueError("Invalid step 4 payload")
|
|
281
|
+
return data[1:45]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class BlerpcCryptoSession:
|
|
285
|
+
"""Encrypt/decrypt with counter management and replay detection."""
|
|
286
|
+
|
|
287
|
+
def __init__(self, session_key: bytes, is_central: bool):
|
|
288
|
+
self._session_key = session_key
|
|
289
|
+
self._tx_counter = 0
|
|
290
|
+
self._rx_counter = 0
|
|
291
|
+
self._rx_first_done = False
|
|
292
|
+
self._tx_direction = DIRECTION_C2P if is_central else DIRECTION_P2C
|
|
293
|
+
self._rx_direction = DIRECTION_P2C if is_central else DIRECTION_C2P
|
|
294
|
+
self._lock = threading.Lock()
|
|
295
|
+
|
|
296
|
+
def encrypt(self, plaintext: bytes) -> bytes:
|
|
297
|
+
with self._lock:
|
|
298
|
+
if self._tx_counter >= 0xFFFFFFFF:
|
|
299
|
+
raise RuntimeError("TX counter overflow: session must be rekeyed")
|
|
300
|
+
encrypted = BlerpcCrypto.encrypt_command(
|
|
301
|
+
self._session_key, self._tx_counter, self._tx_direction, plaintext
|
|
302
|
+
)
|
|
303
|
+
self._tx_counter += 1
|
|
304
|
+
return encrypted
|
|
305
|
+
|
|
306
|
+
def decrypt(self, data: bytes) -> bytes:
|
|
307
|
+
with self._lock:
|
|
308
|
+
counter, plaintext = BlerpcCrypto.decrypt_command(
|
|
309
|
+
self._session_key, self._rx_direction, data
|
|
310
|
+
)
|
|
311
|
+
if self._rx_first_done and counter <= self._rx_counter:
|
|
312
|
+
raise RuntimeError(f"Replay detected: counter={counter}")
|
|
313
|
+
self._rx_counter = counter
|
|
314
|
+
self._rx_first_done = True
|
|
315
|
+
return plaintext
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class CentralKeyExchange:
|
|
319
|
+
"""Central-side key exchange state machine.
|
|
320
|
+
|
|
321
|
+
Usage:
|
|
322
|
+
kx = CentralKeyExchange()
|
|
323
|
+
step1_payload = kx.start() # send to peripheral
|
|
324
|
+
step3_payload = kx.process_step2(step2_payload) # send to peripheral
|
|
325
|
+
session = kx.finish(step4_payload) # BlerpcCryptoSession
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
def __init__(self) -> None:
|
|
329
|
+
self._x25519_privkey: X25519PrivateKey | None = None
|
|
330
|
+
self._x25519_pubkey: bytes | None = None
|
|
331
|
+
self._session_key: bytes | None = None
|
|
332
|
+
self._state = 0
|
|
333
|
+
|
|
334
|
+
def start(self) -> bytes:
|
|
335
|
+
"""Generate ephemeral X25519 keypair and return step 1 payload."""
|
|
336
|
+
if self._state != 0:
|
|
337
|
+
raise RuntimeError("Invalid state for start()")
|
|
338
|
+
self._x25519_privkey, self._x25519_pubkey = (
|
|
339
|
+
BlerpcCrypto.generate_x25519_keypair()
|
|
340
|
+
)
|
|
341
|
+
self._state = 1
|
|
342
|
+
return BlerpcCrypto.build_step1_payload(self._x25519_pubkey)
|
|
343
|
+
|
|
344
|
+
def process_step2(
|
|
345
|
+
self,
|
|
346
|
+
step2_payload: bytes,
|
|
347
|
+
verify_key_cb: Callable[[bytes], bool] | None = None,
|
|
348
|
+
) -> bytes:
|
|
349
|
+
"""Parse step 2, verify signature, derive session key, return step 3 payload.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
step2_payload: Raw step 2 payload from peripheral.
|
|
353
|
+
verify_key_cb: Optional callback receiving the peripheral's Ed25519 public
|
|
354
|
+
key (32 bytes). Return True to accept, False to reject (e.g. TOFU).
|
|
355
|
+
If None, any valid signature is accepted.
|
|
356
|
+
|
|
357
|
+
Raises:
|
|
358
|
+
ValueError: If signature verification fails or verify_key_cb rejects.
|
|
359
|
+
"""
|
|
360
|
+
if self._state != 1:
|
|
361
|
+
raise RuntimeError("Invalid state for process_step2()")
|
|
362
|
+
periph_x25519_pub, signature, periph_ed25519_pub = (
|
|
363
|
+
BlerpcCrypto.parse_step2_payload(step2_payload)
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
sign_msg = self._x25519_pubkey + periph_x25519_pub
|
|
367
|
+
if not BlerpcCrypto.ed25519_verify(periph_ed25519_pub, sign_msg, signature):
|
|
368
|
+
raise ValueError("Ed25519 signature verification failed")
|
|
369
|
+
|
|
370
|
+
if verify_key_cb is not None and not verify_key_cb(periph_ed25519_pub):
|
|
371
|
+
raise ValueError("Peripheral key rejected by verify callback")
|
|
372
|
+
|
|
373
|
+
shared_secret = BlerpcCrypto.x25519_shared_secret(
|
|
374
|
+
self._x25519_privkey, periph_x25519_pub
|
|
375
|
+
)
|
|
376
|
+
self._session_key = BlerpcCrypto.derive_session_key(
|
|
377
|
+
shared_secret, self._x25519_pubkey, periph_x25519_pub
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
encrypted_confirm = BlerpcCrypto.encrypt_confirmation(
|
|
381
|
+
self._session_key, CONFIRM_CENTRAL
|
|
382
|
+
)
|
|
383
|
+
self._state = 2
|
|
384
|
+
return BlerpcCrypto.build_step3_payload(encrypted_confirm)
|
|
385
|
+
|
|
386
|
+
def finish(self, step4_payload: bytes) -> BlerpcCryptoSession:
|
|
387
|
+
"""Parse step 4, verify peripheral confirmation, return session.
|
|
388
|
+
|
|
389
|
+
Raises:
|
|
390
|
+
ValueError: If confirmation verification fails.
|
|
391
|
+
"""
|
|
392
|
+
if self._state != 2:
|
|
393
|
+
raise RuntimeError("Invalid state for finish()")
|
|
394
|
+
encrypted_periph = BlerpcCrypto.parse_step4_payload(step4_payload)
|
|
395
|
+
plaintext = BlerpcCrypto.decrypt_confirmation(
|
|
396
|
+
self._session_key, encrypted_periph
|
|
397
|
+
)
|
|
398
|
+
if plaintext != CONFIRM_PERIPHERAL:
|
|
399
|
+
raise ValueError("Peripheral confirmation mismatch")
|
|
400
|
+
|
|
401
|
+
return BlerpcCryptoSession(self._session_key, is_central=True)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class PeripheralKeyExchange:
|
|
405
|
+
"""Peripheral-side key exchange state machine.
|
|
406
|
+
|
|
407
|
+
Usage:
|
|
408
|
+
kx = PeripheralKeyExchange(ed25519_privkey)
|
|
409
|
+
step2_payload = kx.process_step1(step1_payload) # send to central
|
|
410
|
+
step4_payload, session = kx.process_step3(step3_payload) # send + session
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
def __init__(
|
|
414
|
+
self,
|
|
415
|
+
ed25519_privkey: Ed25519PrivateKey,
|
|
416
|
+
) -> None:
|
|
417
|
+
self._ed25519_privkey = ed25519_privkey
|
|
418
|
+
self._ed25519_pubkey = BlerpcCrypto.ed25519_public_bytes(ed25519_privkey)
|
|
419
|
+
self._session_key: bytes | None = None
|
|
420
|
+
self._state = 0
|
|
421
|
+
|
|
422
|
+
def process_step1(self, step1_payload: bytes) -> bytes:
|
|
423
|
+
"""Parse step 1, generate ephemeral X25519 keypair, sign,
|
|
424
|
+
derive session key, return step 2 payload."""
|
|
425
|
+
if self._state != 0:
|
|
426
|
+
raise RuntimeError("Invalid state for process_step1()")
|
|
427
|
+
central_x25519_pubkey = BlerpcCrypto.parse_step1_payload(step1_payload)
|
|
428
|
+
|
|
429
|
+
# Generate ephemeral X25519 keypair for forward secrecy
|
|
430
|
+
x25519_privkey, x25519_pubkey = BlerpcCrypto.generate_x25519_keypair()
|
|
431
|
+
|
|
432
|
+
sign_msg = central_x25519_pubkey + x25519_pubkey
|
|
433
|
+
signature = BlerpcCrypto.ed25519_sign(self._ed25519_privkey, sign_msg)
|
|
434
|
+
|
|
435
|
+
shared_secret = BlerpcCrypto.x25519_shared_secret(
|
|
436
|
+
x25519_privkey, central_x25519_pubkey
|
|
437
|
+
)
|
|
438
|
+
self._session_key = BlerpcCrypto.derive_session_key(
|
|
439
|
+
shared_secret, central_x25519_pubkey, x25519_pubkey
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
self._state = 1
|
|
443
|
+
return BlerpcCrypto.build_step2_payload(
|
|
444
|
+
x25519_pubkey, signature, self._ed25519_pubkey
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
def process_step3(self, step3_payload: bytes) -> tuple[bytes, BlerpcCryptoSession]:
|
|
448
|
+
"""Parse step 3, verify confirmation, return (step4_payload, session).
|
|
449
|
+
|
|
450
|
+
Raises:
|
|
451
|
+
ValueError: If central confirmation verification fails.
|
|
452
|
+
"""
|
|
453
|
+
if self._state != 1:
|
|
454
|
+
raise RuntimeError("Invalid state for process_step3()")
|
|
455
|
+
encrypted = BlerpcCrypto.parse_step3_payload(step3_payload)
|
|
456
|
+
plaintext = BlerpcCrypto.decrypt_confirmation(self._session_key, encrypted)
|
|
457
|
+
if plaintext != CONFIRM_CENTRAL:
|
|
458
|
+
raise ValueError("Central confirmation mismatch")
|
|
459
|
+
|
|
460
|
+
encrypted_confirm = BlerpcCrypto.encrypt_confirmation(
|
|
461
|
+
self._session_key, CONFIRM_PERIPHERAL
|
|
462
|
+
)
|
|
463
|
+
step4 = BlerpcCrypto.build_step4_payload(encrypted_confirm)
|
|
464
|
+
session = BlerpcCryptoSession(self._session_key, is_central=False)
|
|
465
|
+
|
|
466
|
+
return step4, session
|
|
467
|
+
|
|
468
|
+
def handle_step(self, payload: bytes) -> tuple[bytes, BlerpcCryptoSession | None]:
|
|
469
|
+
"""Dispatch a key exchange payload by step byte.
|
|
470
|
+
|
|
471
|
+
Returns (response_payload, session_or_none).
|
|
472
|
+
Session is returned only after step 3 completes successfully.
|
|
473
|
+
|
|
474
|
+
Raises:
|
|
475
|
+
ValueError: If the step byte is invalid or processing fails.
|
|
476
|
+
"""
|
|
477
|
+
if len(payload) < 1:
|
|
478
|
+
raise ValueError("Empty key exchange payload")
|
|
479
|
+
|
|
480
|
+
step = payload[0]
|
|
481
|
+
if step == KEY_EXCHANGE_STEP1:
|
|
482
|
+
if self._state != 0:
|
|
483
|
+
raise RuntimeError("Invalid state for step 1")
|
|
484
|
+
return self.process_step1(payload), None
|
|
485
|
+
elif step == KEY_EXCHANGE_STEP3:
|
|
486
|
+
if self._state != 1:
|
|
487
|
+
raise RuntimeError("Invalid state for step 3")
|
|
488
|
+
step4, session = self.process_step3(payload)
|
|
489
|
+
return step4, session
|
|
490
|
+
else:
|
|
491
|
+
raise ValueError(f"Invalid key exchange step: 0x{step:02x}")
|
|
492
|
+
|
|
493
|
+
def reset(self) -> None:
|
|
494
|
+
"""Reset key exchange state for new connection."""
|
|
495
|
+
self._state = 0
|
|
496
|
+
self._session_key = None
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class KnownKeyStore(Protocol):
|
|
500
|
+
"""TOFU (Trust On First Use) store for peripheral Ed25519 identity keys.
|
|
501
|
+
|
|
502
|
+
The E2E handshake signature binds only the ephemeral X25519 keys, not the
|
|
503
|
+
peripheral's long-term identity key, so MitM resistance depends on the
|
|
504
|
+
central pinning that identity. Implementations supply platform-appropriate
|
|
505
|
+
persistence (file, keyring, …); this library owns the pinning policy and
|
|
506
|
+
logic (:func:`tofu_verify`).
|
|
507
|
+
"""
|
|
508
|
+
|
|
509
|
+
def get(self, device_id: str) -> str | None:
|
|
510
|
+
"""Return the stored hex-encoded Ed25519 public key, or None if unknown."""
|
|
511
|
+
...
|
|
512
|
+
|
|
513
|
+
def put(self, device_id: str, hex_ed25519_pubkey: str) -> None:
|
|
514
|
+
"""Persist the hex-encoded Ed25519 public key for ``device_id``."""
|
|
515
|
+
...
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def tofu_verify(store: KnownKeyStore, device_id: str, ed25519_pubkey: bytes) -> bool:
|
|
519
|
+
"""Verify a peripheral identity (TOFU) against ``store``.
|
|
520
|
+
|
|
521
|
+
Trust (and pin) the key on first use; reject a key that differs from the
|
|
522
|
+
pinned one on later connections.
|
|
523
|
+
"""
|
|
524
|
+
hex_key = ed25519_pubkey.hex()
|
|
525
|
+
stored = store.get(device_id)
|
|
526
|
+
if stored is None:
|
|
527
|
+
store.put(device_id, hex_key)
|
|
528
|
+
return True
|
|
529
|
+
return stored == hex_key
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
async def central_perform_key_exchange(
|
|
533
|
+
send: Callable[[bytes], Awaitable[None]],
|
|
534
|
+
receive: Callable[[], Awaitable[bytes]],
|
|
535
|
+
known_keys: KnownKeyStore | None = None,
|
|
536
|
+
device_id: str | None = None,
|
|
537
|
+
pin_identity: bool = True,
|
|
538
|
+
verify_key_cb: Callable[[bytes], bool] | None = None,
|
|
539
|
+
) -> BlerpcCryptoSession:
|
|
540
|
+
"""Perform the 4-step central key exchange using send/receive callbacks.
|
|
541
|
+
|
|
542
|
+
Identity pinning is **on by default** (fail-closed): pass ``known_keys`` and
|
|
543
|
+
``device_id`` to pin the peripheral's Ed25519 identity (TOFU), or set
|
|
544
|
+
``pin_identity`` to False to opt out (encrypted but NOT authenticated).
|
|
545
|
+
``verify_key_cb`` is an escape hatch for custom verification and takes
|
|
546
|
+
precedence when provided.
|
|
547
|
+
|
|
548
|
+
Args:
|
|
549
|
+
send: Async callback to send a key exchange payload.
|
|
550
|
+
receive: Async callback to receive a key exchange payload.
|
|
551
|
+
known_keys: TOFU store to pin the peripheral identity against.
|
|
552
|
+
device_id: Stable identifier of the peripheral, used as the pin key.
|
|
553
|
+
pin_identity: Pin the peripheral identity (default True).
|
|
554
|
+
verify_key_cb: Optional custom verification callback (takes precedence).
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
An established BlerpcCryptoSession.
|
|
558
|
+
|
|
559
|
+
Raises:
|
|
560
|
+
ValueError: If any step fails, or if pinning is on (the default) but no
|
|
561
|
+
``known_keys``/``device_id`` and no ``verify_key_cb`` were supplied.
|
|
562
|
+
"""
|
|
563
|
+
effective_verify_cb: Callable[[bytes], bool] | None = None
|
|
564
|
+
if verify_key_cb is not None:
|
|
565
|
+
effective_verify_cb = verify_key_cb
|
|
566
|
+
elif pin_identity:
|
|
567
|
+
if known_keys is None or device_id is None:
|
|
568
|
+
raise ValueError(
|
|
569
|
+
"Identity pinning is on by default but no KnownKeyStore/device_id "
|
|
570
|
+
"was provided. Pass known_keys and device_id to pin the peripheral "
|
|
571
|
+
"identity (TOFU), or set pin_identity=False to opt out (encrypted "
|
|
572
|
+
"but unauthenticated)."
|
|
573
|
+
)
|
|
574
|
+
store = known_keys
|
|
575
|
+
dev_id = device_id
|
|
576
|
+
|
|
577
|
+
def _tofu_cb(pub: bytes) -> bool:
|
|
578
|
+
return tofu_verify(store, dev_id, pub)
|
|
579
|
+
|
|
580
|
+
effective_verify_cb = _tofu_cb
|
|
581
|
+
|
|
582
|
+
kx = CentralKeyExchange()
|
|
583
|
+
|
|
584
|
+
# Step 1: Send central's ephemeral public key
|
|
585
|
+
step1 = kx.start()
|
|
586
|
+
await send(step1)
|
|
587
|
+
|
|
588
|
+
# Step 2: Receive peripheral's response
|
|
589
|
+
step2 = await receive()
|
|
590
|
+
|
|
591
|
+
# Step 2 -> Step 3: Verify and produce confirmation
|
|
592
|
+
step3 = kx.process_step2(step2, verify_key_cb=effective_verify_cb)
|
|
593
|
+
await send(step3)
|
|
594
|
+
|
|
595
|
+
# Step 4: Receive peripheral's confirmation
|
|
596
|
+
step4 = await receive()
|
|
597
|
+
|
|
598
|
+
return kx.finish(step4)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blerpc-protocol
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: Container and command protocol layers for BLE RPC
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/tdaira/blerpc-protocol
|
|
7
|
+
Project-URL: Repository, https://github.com/tdaira/blerpc-protocol
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Classifier: Topic :: System :: Networking
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: cryptography>=41.0
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# blerpc-protocol
|
|
23
|
+
|
|
24
|
+
BLE RPC protocol library for Python and C.
|
|
25
|
+
|
|
26
|
+
Part of the [bleRPC](https://blerpc.net) project.
|
|
27
|
+
|
|
28
|
+
## Overview
|
|
29
|
+
|
|
30
|
+
Python and C implementation of the bleRPC binary protocol:
|
|
31
|
+
|
|
32
|
+
- Container fragmentation and reassembly with MTU-aware splitting
|
|
33
|
+
- Command packet encoding/decoding with protobuf payload support
|
|
34
|
+
- Control messages (timeout, stream end, capabilities, error)
|
|
35
|
+
- **Encryption layer** — E2E encryption with X25519 key exchange, Ed25519 signatures, and AES-128-GCM
|
|
36
|
+
|
|
37
|
+
The Python and C implementations are fully compatible and share the same wire format.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
pip install blerpc-protocol
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from blerpc_protocol import ContainerSplitter, ContainerAssembler, CommandPacket, CommandType
|
|
49
|
+
|
|
50
|
+
# Encode a command
|
|
51
|
+
packet = CommandPacket(CommandType.REQUEST, "Echo", protobuf_bytes)
|
|
52
|
+
payload = packet.serialize()
|
|
53
|
+
|
|
54
|
+
# Split into BLE-sized containers
|
|
55
|
+
splitter = ContainerSplitter(mtu=247)
|
|
56
|
+
containers = splitter.split(payload)
|
|
57
|
+
|
|
58
|
+
# Send containers over BLE, then reassemble on the other side
|
|
59
|
+
assembler = ContainerAssembler()
|
|
60
|
+
for container in received_containers:
|
|
61
|
+
result = assembler.feed(container)
|
|
62
|
+
if result is not None:
|
|
63
|
+
response = CommandPacket.deserialize(result)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Encryption
|
|
67
|
+
|
|
68
|
+
The library provides E2E encryption using a 4-step key exchange protocol (X25519 ECDH + Ed25519 signatures) and AES-128-GCM session encryption.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from blerpc_protocol.crypto import central_perform_key_exchange, BlerpcCryptoSession
|
|
72
|
+
|
|
73
|
+
# Perform key exchange (central side)
|
|
74
|
+
session = await central_perform_key_exchange(send=ble_send, receive=ble_receive)
|
|
75
|
+
|
|
76
|
+
# Encrypt outgoing commands
|
|
77
|
+
ciphertext = session.encrypt(plaintext)
|
|
78
|
+
|
|
79
|
+
# Decrypt incoming commands
|
|
80
|
+
plaintext = session.decrypt(ciphertext)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## C Library
|
|
84
|
+
|
|
85
|
+
The C implementation is a Zephyr module with zero external dependencies. Add it to your `west.yml` manifest:
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
- name: blerpc-protocol
|
|
89
|
+
url: https://github.com/tdaira/blerpc-protocol
|
|
90
|
+
revision: main
|
|
91
|
+
path: modules/lib/blerpc-protocol
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Headers are in `c/include/blerpc_protocol/`. See [container.h](c/include/blerpc_protocol/container.h), [command.h](c/include/blerpc_protocol/command.h), and [crypto.h](c/include/blerpc_protocol/crypto.h) for the API.
|
|
95
|
+
|
|
96
|
+
## Requirements
|
|
97
|
+
|
|
98
|
+
- Python 3.11+
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
[Apache-2.0](LICENSE)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
blerpc_protocol/__init__.py,sha256=2BgZ1u-suenxFfjSqIalJq5LxePIo-ZjzE-NeToT-2k,1546
|
|
2
|
+
blerpc_protocol/command.py,sha256=XlIvFHZE25TR-5dt3dV-SFlEJBip2CdT0tOKp4Aacos,2090
|
|
3
|
+
blerpc_protocol/container.py,sha256=Km_Ew1rTUej1FNACv17bZXdq57UcruLiXP-5X8ui3vo,12640
|
|
4
|
+
blerpc_protocol/crypto.py,sha256=Lbu4L14fmDUFuAtcUI0ERneUI0sCi71YdfR8ugLS8Bo,21980
|
|
5
|
+
blerpc_protocol-0.8.0.dist-info/licenses/LICENSE,sha256=qHK2c7bu8O4yjPR3AF133UThcnVxM98r9uXKmM22rPA,547
|
|
6
|
+
blerpc_protocol-0.8.0.dist-info/METADATA,sha256=QaV6uTctqH1gu44-5il3iRI0KWPm9ZPKbDrCULR0Up4,3100
|
|
7
|
+
blerpc_protocol-0.8.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
blerpc_protocol-0.8.0.dist-info/top_level.txt,sha256=nJrJWULXoQX-r-uykVf9tklDEEPS-Jr-Y1MOVg2e8wI,16
|
|
9
|
+
blerpc_protocol-0.8.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2026 tdaira
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
blerpc_protocol
|