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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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