usmp 0.2.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.
- usmp/__init__.py +55 -0
- usmp/_client.py +68 -0
- usmp/_crypto.py +121 -0
- usmp/_frame.py +137 -0
- usmp/_handshake.py +158 -0
- usmp/_server.py +174 -0
- usmp/_session.py +131 -0
- usmp/errors.py +49 -0
- usmp/py.typed +0 -0
- usmp/types.py +87 -0
- usmp-0.2.0.dist-info/METADATA +119 -0
- usmp-0.2.0.dist-info/RECORD +13 -0
- usmp-0.2.0.dist-info/WHEEL +4 -0
usmp/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# src/usmp/__init__.py
|
|
2
|
+
|
|
3
|
+
from .types import USMPFrame, SessionInfo, PacketType, ErrorCode
|
|
4
|
+
from .errors import (
|
|
5
|
+
USMPError,
|
|
6
|
+
FrameError,
|
|
7
|
+
CRCError,
|
|
8
|
+
MagicError,
|
|
9
|
+
VersionError,
|
|
10
|
+
PayloadError,
|
|
11
|
+
HandshakeError,
|
|
12
|
+
AuthError,
|
|
13
|
+
CryptoError,
|
|
14
|
+
SequenceError,
|
|
15
|
+
TimeoutError,
|
|
16
|
+
ConnectionClosedError,
|
|
17
|
+
)
|
|
18
|
+
from ._frame import encode_frame, decode_frame, read_frame, write_frame
|
|
19
|
+
from ._session import USMPSession
|
|
20
|
+
from ._server import USMPServer
|
|
21
|
+
from ._client import USMPClient
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
# Types
|
|
25
|
+
"USMPFrame",
|
|
26
|
+
"SessionInfo",
|
|
27
|
+
"PacketType",
|
|
28
|
+
"ErrorCode",
|
|
29
|
+
# Errors
|
|
30
|
+
"USMPError",
|
|
31
|
+
"FrameError",
|
|
32
|
+
"CRCError",
|
|
33
|
+
"MagicError",
|
|
34
|
+
"VersionError",
|
|
35
|
+
"PayloadError",
|
|
36
|
+
"HandshakeError",
|
|
37
|
+
"AuthError",
|
|
38
|
+
"CryptoError",
|
|
39
|
+
"SequenceError",
|
|
40
|
+
"TimeoutError",
|
|
41
|
+
"ConnectionClosedError",
|
|
42
|
+
# Frame
|
|
43
|
+
"encode_frame",
|
|
44
|
+
"decode_frame",
|
|
45
|
+
"read_frame",
|
|
46
|
+
"write_frame",
|
|
47
|
+
# Session
|
|
48
|
+
"USMPSession",
|
|
49
|
+
# Server
|
|
50
|
+
"USMPServer",
|
|
51
|
+
# Client
|
|
52
|
+
"USMPClient",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
__version__ = "0.2.0"
|
usmp/_client.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# src/usmp/_client.py
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from ._handshake import client_handshake
|
|
6
|
+
from ._session import USMPSession
|
|
7
|
+
from .types import USMP_DEVICE_ID_LEN
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class USMPClient:
|
|
11
|
+
"""
|
|
12
|
+
Asyncio USMP client. Used to connect to a USMP server from Python.
|
|
13
|
+
Useful for testing, CLI tools, or Python-to-Python USMP communication.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
client = USMPClient(host="192.168.137.1", port=9000, psk=b"your-psk")
|
|
17
|
+
await client.connect()
|
|
18
|
+
await client.send(b"hello")
|
|
19
|
+
data = await client.recv()
|
|
20
|
+
await client.disconnect()
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
host: str,
|
|
26
|
+
port: int,
|
|
27
|
+
psk: bytes,
|
|
28
|
+
device_id: bytes | None = None,
|
|
29
|
+
):
|
|
30
|
+
self._host = host
|
|
31
|
+
self._port = port
|
|
32
|
+
self._psk = psk
|
|
33
|
+
self._device_id = device_id or os.urandom(USMP_DEVICE_ID_LEN)
|
|
34
|
+
self._session: USMPSession | None = None
|
|
35
|
+
|
|
36
|
+
async def connect(self) -> None:
|
|
37
|
+
"""Connect to a USMP server and complete the handshake."""
|
|
38
|
+
reader, writer = await asyncio.open_connection(self._host, self._port)
|
|
39
|
+
info = await client_handshake(reader, writer, self._psk, self._device_id)
|
|
40
|
+
self._session = USMPSession(reader, writer, info)
|
|
41
|
+
print(
|
|
42
|
+
f"[USMP] Connected to {self._host}:{self._port} session={info.session_id_str}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
async def send(self, data: bytes) -> None:
|
|
46
|
+
self._ensure_connected()
|
|
47
|
+
await self._session.send(data)
|
|
48
|
+
|
|
49
|
+
async def recv(self) -> bytes:
|
|
50
|
+
self._ensure_connected()
|
|
51
|
+
return await self._session.recv()
|
|
52
|
+
|
|
53
|
+
async def ping(self) -> None:
|
|
54
|
+
self._ensure_connected()
|
|
55
|
+
await self._session.ping()
|
|
56
|
+
|
|
57
|
+
async def disconnect(self) -> None:
|
|
58
|
+
if self._session:
|
|
59
|
+
await self._session.bye()
|
|
60
|
+
self._session = None
|
|
61
|
+
|
|
62
|
+
def _ensure_connected(self) -> None:
|
|
63
|
+
if self._session is None:
|
|
64
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def session_id(self) -> str | None:
|
|
68
|
+
return self._session.session_id if self._session else None
|
usmp/_crypto.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# src/usmp/_crypto.py
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import (
|
|
5
|
+
X25519PrivateKey,
|
|
6
|
+
X25519PublicKey,
|
|
7
|
+
)
|
|
8
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
9
|
+
from cryptography.hazmat.primitives import hashes
|
|
10
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
11
|
+
from .types import USMP_SESSION_KEY_LEN, USMP_TAG_LEN
|
|
12
|
+
from .errors import CryptoError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def generate_keypair() -> tuple[X25519PrivateKey, bytes]:
|
|
16
|
+
"""Generate an ephemeral X25519 keypair. Returns (private_key, public_key_bytes)."""
|
|
17
|
+
priv = X25519PrivateKey.generate()
|
|
18
|
+
pub = priv.public_key().public_bytes_raw()
|
|
19
|
+
return priv, pub
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def derive_session_key(
|
|
23
|
+
priv_key: X25519PrivateKey,
|
|
24
|
+
peer_pub: bytes,
|
|
25
|
+
nonce: bytes,
|
|
26
|
+
pub_c: bytes,
|
|
27
|
+
pub_s: bytes,
|
|
28
|
+
) -> bytes:
|
|
29
|
+
"""
|
|
30
|
+
Derive session key via X25519 + HKDF-SHA256.
|
|
31
|
+
|
|
32
|
+
session_key = HKDF-SHA256(
|
|
33
|
+
ikm = X25519(priv, peer_pub),
|
|
34
|
+
salt = nonce,
|
|
35
|
+
info = "usmp-v1" || pub_C || pub_S,
|
|
36
|
+
len = 32
|
|
37
|
+
)
|
|
38
|
+
"""
|
|
39
|
+
peer_key = X25519PublicKey.from_public_bytes(peer_pub)
|
|
40
|
+
shared_secret = priv_key.exchange(peer_key)
|
|
41
|
+
info = b"usmp-v1" + pub_c + pub_s
|
|
42
|
+
|
|
43
|
+
return HKDF(
|
|
44
|
+
algorithm=hashes.SHA256(),
|
|
45
|
+
length=USMP_SESSION_KEY_LEN,
|
|
46
|
+
salt=nonce,
|
|
47
|
+
info=info,
|
|
48
|
+
).derive(shared_secret)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_gcm_nonce(seq: int, session_id: bytes) -> bytes:
|
|
52
|
+
"""Build a 12-byte GCM nonce: seq(4 LE) || session_id(4) || 0x00000000(4)."""
|
|
53
|
+
return struct.pack("<I", seq) + session_id[:4] + b"\x00" * 4
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_aad(
|
|
57
|
+
magic: int,
|
|
58
|
+
version: int,
|
|
59
|
+
type_: int,
|
|
60
|
+
seq: int,
|
|
61
|
+
length: int,
|
|
62
|
+
) -> bytes:
|
|
63
|
+
"""
|
|
64
|
+
Build AAD for AES-GCM.
|
|
65
|
+
AAD = magic(2 LE) || version(1) || type(1) || seq(4 LE) || length(2 LE)
|
|
66
|
+
Total: 10 bytes.
|
|
67
|
+
"""
|
|
68
|
+
return (
|
|
69
|
+
struct.pack("<H", magic)
|
|
70
|
+
+ struct.pack("<B", version)
|
|
71
|
+
+ struct.pack("<B", type_)
|
|
72
|
+
+ struct.pack("<I", seq)
|
|
73
|
+
+ struct.pack("<H", length)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def encrypt(
|
|
78
|
+
key: bytes,
|
|
79
|
+
seq: int,
|
|
80
|
+
session_id: bytes,
|
|
81
|
+
type_: int,
|
|
82
|
+
version: int,
|
|
83
|
+
magic: int,
|
|
84
|
+
plaintext: bytes,
|
|
85
|
+
) -> bytes:
|
|
86
|
+
"""
|
|
87
|
+
Encrypt plaintext with AES-256-GCM.
|
|
88
|
+
Returns ciphertext + tag (len(plaintext) + 16 bytes).
|
|
89
|
+
"""
|
|
90
|
+
nonce = build_gcm_nonce(seq, session_id)
|
|
91
|
+
# AAD uses the post-encryption length (plaintext + tag)
|
|
92
|
+
enc_length = len(plaintext) + USMP_TAG_LEN
|
|
93
|
+
aad = build_aad(magic, version, type_, seq, enc_length)
|
|
94
|
+
|
|
95
|
+
aesgcm = AESGCM(key)
|
|
96
|
+
# cryptography library appends tag to ciphertext automatically
|
|
97
|
+
return aesgcm.encrypt(nonce, plaintext, aad)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def decrypt(
|
|
101
|
+
key: bytes,
|
|
102
|
+
seq: int,
|
|
103
|
+
session_id: bytes,
|
|
104
|
+
type_: int,
|
|
105
|
+
version: int,
|
|
106
|
+
magic: int,
|
|
107
|
+
length: int,
|
|
108
|
+
ciphertext_and_tag: bytes,
|
|
109
|
+
) -> bytes:
|
|
110
|
+
"""
|
|
111
|
+
Decrypt and verify AES-256-GCM ciphertext+tag.
|
|
112
|
+
Raises CryptoError if authentication fails.
|
|
113
|
+
"""
|
|
114
|
+
nonce = build_gcm_nonce(seq, session_id)
|
|
115
|
+
aad = build_aad(magic, version, type_, seq, length)
|
|
116
|
+
|
|
117
|
+
aesgcm = AESGCM(key)
|
|
118
|
+
try:
|
|
119
|
+
return aesgcm.decrypt(nonce, ciphertext_and_tag, aad)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
raise CryptoError(f"Decryption failed: {e}") from e
|
usmp/_frame.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# src/usmp/_frame.py
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
from .types import (
|
|
5
|
+
USMPFrame,
|
|
6
|
+
PacketType,
|
|
7
|
+
USMP_MAGIC,
|
|
8
|
+
USMP_VERSION,
|
|
9
|
+
USMP_HEADER_SIZE,
|
|
10
|
+
USMP_MAX_PAYLOAD,
|
|
11
|
+
)
|
|
12
|
+
from .errors import MagicError, VersionError, PayloadError, CRCError, FrameError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def crc16(data: bytes) -> int:
|
|
16
|
+
"""CRC-16/IBM — polynomial 0xA001, initial value 0xFFFF."""
|
|
17
|
+
crc = 0xFFFF
|
|
18
|
+
for byte in data:
|
|
19
|
+
crc ^= byte
|
|
20
|
+
for _ in range(8):
|
|
21
|
+
if crc & 1:
|
|
22
|
+
crc = (crc >> 1) ^ 0xA001
|
|
23
|
+
else:
|
|
24
|
+
crc >>= 1
|
|
25
|
+
return crc
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def compute_frame_crc(
|
|
29
|
+
magic: int,
|
|
30
|
+
version: int,
|
|
31
|
+
type_: int,
|
|
32
|
+
seq: int,
|
|
33
|
+
length: int,
|
|
34
|
+
payload: bytes,
|
|
35
|
+
) -> int:
|
|
36
|
+
"""Compute CRC over header bytes [0..9] + payload."""
|
|
37
|
+
header = struct.pack("<HBBI", magic, version, type_, seq)
|
|
38
|
+
header += struct.pack("<H", length)
|
|
39
|
+
return crc16(header + payload)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def encode_frame(
|
|
43
|
+
type_: PacketType,
|
|
44
|
+
payload: bytes,
|
|
45
|
+
seq: int = 0,
|
|
46
|
+
version: int = USMP_VERSION,
|
|
47
|
+
) -> bytes:
|
|
48
|
+
"""Encode a USMP frame to bytes."""
|
|
49
|
+
if len(payload) > USMP_MAX_PAYLOAD:
|
|
50
|
+
raise PayloadError(
|
|
51
|
+
f"Payload too large: {len(payload)} bytes, max {USMP_MAX_PAYLOAD}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
length = len(payload)
|
|
55
|
+
crc = compute_frame_crc(USMP_MAGIC, version, int(type_), seq, length, payload)
|
|
56
|
+
|
|
57
|
+
header = struct.pack("<HBBI", USMP_MAGIC, version, int(type_), seq)
|
|
58
|
+
header += struct.pack("<HH", length, crc)
|
|
59
|
+
return header + payload
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def decode_frame(data: bytes, verify_crc: bool = True) -> USMPFrame:
|
|
63
|
+
"""Decode a USMP frame from bytes."""
|
|
64
|
+
if len(data) < USMP_HEADER_SIZE:
|
|
65
|
+
raise FrameError(f"Frame too short: {len(data)} bytes, need {USMP_HEADER_SIZE}")
|
|
66
|
+
|
|
67
|
+
magic = struct.unpack_from("<H", data, 0)[0]
|
|
68
|
+
version = data[2]
|
|
69
|
+
type_ = data[3]
|
|
70
|
+
seq = struct.unpack_from("<I", data, 4)[0]
|
|
71
|
+
length = struct.unpack_from("<H", data, 8)[0]
|
|
72
|
+
crc = struct.unpack_from("<H", data, 10)[0]
|
|
73
|
+
|
|
74
|
+
if magic != USMP_MAGIC:
|
|
75
|
+
raise MagicError(f"Bad magic: 0x{magic:04X}, expected 0x{USMP_MAGIC:04X}")
|
|
76
|
+
|
|
77
|
+
if version != USMP_VERSION:
|
|
78
|
+
raise VersionError(f"Unsupported version: {version}")
|
|
79
|
+
|
|
80
|
+
if length > USMP_MAX_PAYLOAD:
|
|
81
|
+
raise PayloadError(f"Payload too large: {length} bytes")
|
|
82
|
+
|
|
83
|
+
if len(data) < USMP_HEADER_SIZE + length:
|
|
84
|
+
raise PayloadError(
|
|
85
|
+
f"Payload truncated: have {len(data) - USMP_HEADER_SIZE}, need {length}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
payload = data[USMP_HEADER_SIZE : USMP_HEADER_SIZE + length]
|
|
89
|
+
|
|
90
|
+
if verify_crc:
|
|
91
|
+
expected_crc = compute_frame_crc(magic, version, type_, seq, length, payload)
|
|
92
|
+
if crc != expected_crc:
|
|
93
|
+
raise CRCError(
|
|
94
|
+
f"CRC mismatch: got 0x{crc:04X}, expected 0x{expected_crc:04X}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return USMPFrame(
|
|
98
|
+
magic=magic,
|
|
99
|
+
version=version,
|
|
100
|
+
type=PacketType(type_),
|
|
101
|
+
seq=seq,
|
|
102
|
+
length=length,
|
|
103
|
+
crc=crc,
|
|
104
|
+
payload=payload,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def read_frame(reader, verify_crc: bool = True) -> USMPFrame:
|
|
109
|
+
"""
|
|
110
|
+
Read exactly one USMP frame from an asyncio StreamReader.
|
|
111
|
+
Reads header first, then exact payload bytes — handles TCP stream fragmentation.
|
|
112
|
+
"""
|
|
113
|
+
header = await reader.readexactly(USMP_HEADER_SIZE)
|
|
114
|
+
|
|
115
|
+
magic = struct.unpack_from("<H", header, 0)[0]
|
|
116
|
+
if magic != USMP_MAGIC:
|
|
117
|
+
raise MagicError(f"Bad magic: 0x{magic:04X}")
|
|
118
|
+
|
|
119
|
+
length = struct.unpack_from("<H", header, 8)[0]
|
|
120
|
+
if length > USMP_MAX_PAYLOAD:
|
|
121
|
+
raise PayloadError(f"Payload too large: {length}")
|
|
122
|
+
|
|
123
|
+
payload = await reader.readexactly(length) if length > 0 else b""
|
|
124
|
+
|
|
125
|
+
return decode_frame(header + payload, verify_crc=verify_crc)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def write_frame(
|
|
129
|
+
writer,
|
|
130
|
+
type_: PacketType,
|
|
131
|
+
payload: bytes,
|
|
132
|
+
seq: int = 0,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Write a USMP frame to an asyncio StreamWriter."""
|
|
135
|
+
data = encode_frame(type_, payload, seq)
|
|
136
|
+
writer.write(data)
|
|
137
|
+
await writer.drain()
|
usmp/_handshake.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import hmac
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
from .types import (
|
|
6
|
+
PacketType,
|
|
7
|
+
SessionInfo,
|
|
8
|
+
USMP_NONCE_LEN,
|
|
9
|
+
USMP_DEVICE_ID_LEN,
|
|
10
|
+
USMP_PUB_KEY_LEN,
|
|
11
|
+
USMP_HMAC_LEN,
|
|
12
|
+
USMP_SESSION_ID_LEN,
|
|
13
|
+
)
|
|
14
|
+
from ._frame import read_frame, write_frame
|
|
15
|
+
from ._crypto import generate_keypair, derive_session_key
|
|
16
|
+
from .errors import HandshakeError, AuthError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _compute_hmac(psk: bytes, *parts: bytes) -> bytes:
|
|
20
|
+
"""HMAC-SHA256(psk, part1 || part2 || ...)"""
|
|
21
|
+
data = b"".join(parts)
|
|
22
|
+
return hmac.new(psk, data, hashlib.sha256).digest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def server_handshake(
|
|
26
|
+
reader,
|
|
27
|
+
writer,
|
|
28
|
+
psk: bytes,
|
|
29
|
+
) -> SessionInfo:
|
|
30
|
+
"""
|
|
31
|
+
Run the server side of the USMP handshake.
|
|
32
|
+
Returns SessionInfo on success, raises HandshakeError on failure.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
# ── Step 1: Receive HELLO [device_id(6) || pub_C(32)] ────────────────────
|
|
36
|
+
try:
|
|
37
|
+
frame = await read_frame(reader, verify_crc=False)
|
|
38
|
+
except asyncio.IncompleteReadError as e:
|
|
39
|
+
raise HandshakeError("Connection closed before HELLO") from e
|
|
40
|
+
|
|
41
|
+
if frame.type != PacketType.HELLO:
|
|
42
|
+
raise HandshakeError(f"Expected HELLO, got {frame.type_name()}")
|
|
43
|
+
|
|
44
|
+
if frame.length != USMP_DEVICE_ID_LEN + USMP_PUB_KEY_LEN:
|
|
45
|
+
raise HandshakeError(f"Bad HELLO length: {frame.length}")
|
|
46
|
+
|
|
47
|
+
device_id = frame.payload[:USMP_DEVICE_ID_LEN]
|
|
48
|
+
pub_c = frame.payload[USMP_DEVICE_ID_LEN : USMP_DEVICE_ID_LEN + USMP_PUB_KEY_LEN]
|
|
49
|
+
|
|
50
|
+
# ── Generate server keypair ───────────────────────────────────────────────
|
|
51
|
+
priv_s, pub_s = generate_keypair()
|
|
52
|
+
|
|
53
|
+
# ── Step 2: Send CHALLENGE [nonce(32) || pub_S(32)] ──────────────────────
|
|
54
|
+
nonce = os.urandom(USMP_NONCE_LEN)
|
|
55
|
+
await write_frame(writer, PacketType.CHALLENGE, nonce + pub_s)
|
|
56
|
+
|
|
57
|
+
# ── Derive session key ────────────────────────────────────────────────────
|
|
58
|
+
session_key = derive_session_key(priv_s, pub_c, nonce, pub_c, pub_s)
|
|
59
|
+
|
|
60
|
+
# ── Step 3: Receive HELLO_ACK [hmac_client(32)] ──────────────────────────
|
|
61
|
+
try:
|
|
62
|
+
frame = await read_frame(reader, verify_crc=False)
|
|
63
|
+
except asyncio.IncompleteReadError as e:
|
|
64
|
+
raise HandshakeError("Connection closed before HELLO_ACK") from e
|
|
65
|
+
|
|
66
|
+
if frame.type != PacketType.HELLO_ACK:
|
|
67
|
+
raise HandshakeError(f"Expected HELLO_ACK, got {frame.type_name()}")
|
|
68
|
+
|
|
69
|
+
if frame.length != USMP_HMAC_LEN:
|
|
70
|
+
raise HandshakeError(f"Bad HELLO_ACK length: {frame.length}")
|
|
71
|
+
|
|
72
|
+
# ── Verify client HMAC ────────────────────────────────────────────────────
|
|
73
|
+
expected_client = _compute_hmac(psk, nonce, device_id)
|
|
74
|
+
received_client = frame.payload[:USMP_HMAC_LEN]
|
|
75
|
+
|
|
76
|
+
if not hmac.compare_digest(expected_client, received_client):
|
|
77
|
+
raise AuthError("Client HMAC verification failed")
|
|
78
|
+
|
|
79
|
+
# ── Step 4: Send SESSION_OK [session_id(4) || hmac_server(32)] ───────────
|
|
80
|
+
session_id = os.urandom(USMP_SESSION_ID_LEN)
|
|
81
|
+
hmac_server = _compute_hmac(psk, nonce, session_id)
|
|
82
|
+
await write_frame(writer, PacketType.SESSION_OK, session_id + hmac_server)
|
|
83
|
+
|
|
84
|
+
return SessionInfo(
|
|
85
|
+
device_id=device_id,
|
|
86
|
+
session_id=session_id,
|
|
87
|
+
session_key=session_key,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def client_handshake(
|
|
92
|
+
reader,
|
|
93
|
+
writer,
|
|
94
|
+
psk: bytes,
|
|
95
|
+
device_id: bytes,
|
|
96
|
+
) -> SessionInfo:
|
|
97
|
+
"""
|
|
98
|
+
Run the client side of the USMP handshake.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
# ── Generate client keypair ───────────────────────────────────────────────
|
|
102
|
+
priv_c, pub_c = generate_keypair()
|
|
103
|
+
|
|
104
|
+
# ── Step 1: Send HELLO [device_id(6) || pub_C(32)] ───────────────────────
|
|
105
|
+
await write_frame(writer, PacketType.HELLO, device_id + pub_c)
|
|
106
|
+
|
|
107
|
+
# ── Step 2: Receive CHALLENGE [nonce(32) || pub_S(32)] ───────────────────
|
|
108
|
+
try:
|
|
109
|
+
frame = await read_frame(reader, verify_crc=False)
|
|
110
|
+
except asyncio.IncompleteReadError as e:
|
|
111
|
+
raise HandshakeError("Connection closed before CHALLENGE") from e
|
|
112
|
+
|
|
113
|
+
if frame.type != PacketType.CHALLENGE:
|
|
114
|
+
raise HandshakeError(f"Expected CHALLENGE, got {frame.type_name()}")
|
|
115
|
+
|
|
116
|
+
if frame.length != USMP_NONCE_LEN + USMP_PUB_KEY_LEN:
|
|
117
|
+
raise HandshakeError(f"Bad CHALLENGE length: {frame.length}")
|
|
118
|
+
|
|
119
|
+
nonce = frame.payload[:USMP_NONCE_LEN]
|
|
120
|
+
pub_s = frame.payload[USMP_NONCE_LEN : USMP_NONCE_LEN + USMP_PUB_KEY_LEN]
|
|
121
|
+
|
|
122
|
+
# ── Derive session key ────────────────────────────────────────────────────
|
|
123
|
+
session_key = derive_session_key(priv_c, pub_s, nonce, pub_c, pub_s)
|
|
124
|
+
|
|
125
|
+
# ── Step 3: Send HELLO_ACK [hmac_client(32)] ─────────────────────────────
|
|
126
|
+
hmac_client = _compute_hmac(psk, nonce, device_id)
|
|
127
|
+
await write_frame(writer, PacketType.HELLO_ACK, hmac_client)
|
|
128
|
+
|
|
129
|
+
# ── Step 4: Receive SESSION_OK [session_id(4) || hmac_server(32)] ────────
|
|
130
|
+
try:
|
|
131
|
+
frame = await read_frame(reader, verify_crc=False)
|
|
132
|
+
except asyncio.IncompleteReadError as e:
|
|
133
|
+
raise HandshakeError(
|
|
134
|
+
"Connection closed by server — PSK rejected or server error"
|
|
135
|
+
) from e
|
|
136
|
+
|
|
137
|
+
if frame.type != PacketType.SESSION_OK:
|
|
138
|
+
raise HandshakeError(f"Expected SESSION_OK, got {frame.type_name()}")
|
|
139
|
+
|
|
140
|
+
expected_len = USMP_SESSION_ID_LEN + USMP_HMAC_LEN
|
|
141
|
+
if frame.length != expected_len:
|
|
142
|
+
raise HandshakeError(f"Bad SESSION_OK length: {frame.length}")
|
|
143
|
+
|
|
144
|
+
session_id = frame.payload[:USMP_SESSION_ID_LEN]
|
|
145
|
+
hmac_server = frame.payload[
|
|
146
|
+
USMP_SESSION_ID_LEN : USMP_SESSION_ID_LEN + USMP_HMAC_LEN
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
# ── Verify server HMAC ────────────────────────────────────────────────────
|
|
150
|
+
expected_server = _compute_hmac(psk, nonce, session_id)
|
|
151
|
+
if not hmac.compare_digest(expected_server, hmac_server):
|
|
152
|
+
raise AuthError("Server HMAC verification failed — possible rogue server")
|
|
153
|
+
|
|
154
|
+
return SessionInfo(
|
|
155
|
+
device_id=device_id,
|
|
156
|
+
session_id=session_id,
|
|
157
|
+
session_key=session_key,
|
|
158
|
+
)
|
usmp/_server.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# src/usmp/_server.py
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from typing import Callable, Awaitable
|
|
7
|
+
from ._handshake import server_handshake
|
|
8
|
+
from ._session import USMPSession
|
|
9
|
+
from .errors import HandshakeError, USMPError
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("usmp")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class USMPServer:
|
|
15
|
+
"""
|
|
16
|
+
Asyncio USMP server. Accepts multiple concurrent device connections.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
server = USMPServer(
|
|
20
|
+
host="0.0.0.0",
|
|
21
|
+
port=9000,
|
|
22
|
+
psk=b"your-psk",
|
|
23
|
+
session_timeout=60.0,
|
|
24
|
+
on_timeout=my_callback, # optional async fn(device_id, session_id)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@server.on_session
|
|
28
|
+
async def handle(session: USMPSession):
|
|
29
|
+
data = await session.recv()
|
|
30
|
+
await session.send(b"ACK")
|
|
31
|
+
|
|
32
|
+
asyncio.run(server.serve())
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
host: str = "0.0.0.0",
|
|
38
|
+
port: int = 9000,
|
|
39
|
+
psk: bytes = b"",
|
|
40
|
+
handshake_timeout: float = 10.0,
|
|
41
|
+
session_timeout: float = 60.0,
|
|
42
|
+
on_timeout: Callable[[str, str], Awaitable[None]] | None = None,
|
|
43
|
+
):
|
|
44
|
+
self._host = host
|
|
45
|
+
self._port = port
|
|
46
|
+
self._psk = psk
|
|
47
|
+
self._handshake_timeout = handshake_timeout
|
|
48
|
+
self._session_timeout = session_timeout
|
|
49
|
+
self._on_timeout = on_timeout
|
|
50
|
+
self._handler: Callable[[USMPSession], Awaitable[None]] | None = None
|
|
51
|
+
|
|
52
|
+
def on_session(
|
|
53
|
+
self,
|
|
54
|
+
fn: Callable[[USMPSession], Awaitable[None]],
|
|
55
|
+
) -> Callable[[USMPSession], Awaitable[None]]:
|
|
56
|
+
"""Decorator to register a session handler."""
|
|
57
|
+
self._handler = fn
|
|
58
|
+
return fn
|
|
59
|
+
|
|
60
|
+
async def _watchdog(self, session: USMPSession) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Monitors session activity. Closes the session if no DATA or PING
|
|
63
|
+
is received within session_timeout seconds.
|
|
64
|
+
Checks every session_timeout/2 seconds to keep the window tight.
|
|
65
|
+
"""
|
|
66
|
+
interval = self._session_timeout / 2
|
|
67
|
+
while True:
|
|
68
|
+
await asyncio.sleep(interval)
|
|
69
|
+
elapsed = time.monotonic() - session._last_recv
|
|
70
|
+
if elapsed > self._session_timeout:
|
|
71
|
+
logger.warning(
|
|
72
|
+
"Session timeout: device=%s session=%s (no activity for %.1fs)",
|
|
73
|
+
session.device_id,
|
|
74
|
+
session.session_id,
|
|
75
|
+
elapsed,
|
|
76
|
+
)
|
|
77
|
+
if self._on_timeout is not None:
|
|
78
|
+
try:
|
|
79
|
+
await self._on_timeout(session.device_id, session.session_id)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.error("on_timeout callback raised: %s", e)
|
|
82
|
+
# Close the underlying writer — causes read_frame to raise in the
|
|
83
|
+
# handler, which unblocks and exits the session naturally
|
|
84
|
+
session._writer.close()
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
async def _handle_client(
|
|
88
|
+
self,
|
|
89
|
+
reader: asyncio.StreamReader,
|
|
90
|
+
writer: asyncio.StreamWriter,
|
|
91
|
+
) -> None:
|
|
92
|
+
addr = writer.get_extra_info("peername")
|
|
93
|
+
logger.info("TCP connected: %s", addr)
|
|
94
|
+
|
|
95
|
+
watchdog_task: asyncio.Task | None = None
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
info = await asyncio.wait_for(
|
|
99
|
+
server_handshake(reader, writer, self._psk),
|
|
100
|
+
timeout=self._handshake_timeout,
|
|
101
|
+
)
|
|
102
|
+
logger.info(
|
|
103
|
+
"Session established: device=%s session=%s",
|
|
104
|
+
info.device_id_str,
|
|
105
|
+
info.session_id_str,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
session = USMPSession(reader, writer, info)
|
|
109
|
+
|
|
110
|
+
# Start watchdog alongside the handler
|
|
111
|
+
watchdog_task = asyncio.create_task(
|
|
112
|
+
self._watchdog(session),
|
|
113
|
+
name=f"usmp-watchdog-{info.session_id_str}",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if self._handler:
|
|
117
|
+
await self._handler(session)
|
|
118
|
+
|
|
119
|
+
except HandshakeError as e:
|
|
120
|
+
logger.warning("Handshake failed (%s): %s", addr, e)
|
|
121
|
+
except asyncio.TimeoutError:
|
|
122
|
+
logger.warning("Handshake timeout (%s)", addr)
|
|
123
|
+
except USMPError as e:
|
|
124
|
+
logger.warning("Protocol error (%s): %s", addr, e)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
logger.error("Unexpected error (%s): %s", addr, e)
|
|
127
|
+
except HandshakeError as e:
|
|
128
|
+
logger.warning("Handshake failed (%s): %s", addr, e)
|
|
129
|
+
except asyncio.TimeoutError:
|
|
130
|
+
logger.warning("Handshake timeout (%s)", addr)
|
|
131
|
+
except USMPError as e:
|
|
132
|
+
logger.warning("Protocol error (%s): %s", addr, e)
|
|
133
|
+
except (OSError, ConnectionResetError, EOFError) as e:
|
|
134
|
+
logger.warning("Connection lost (%s): %s", addr, e)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
logger.error("Unexpected error (%s): %s", addr, e)
|
|
137
|
+
except (OSError, ConnectionResetError, EOFError) as e:
|
|
138
|
+
logger.warning("Connection lost (%s): %s", addr, e)
|
|
139
|
+
except asyncio.IncompleteReadError:
|
|
140
|
+
logger.warning("Connection closed mid-frame (%s)", addr)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.error("Unexpected error (%s): %s", addr, e)
|
|
143
|
+
|
|
144
|
+
finally:
|
|
145
|
+
# Always cancel watchdog when handler exits for any reason
|
|
146
|
+
if watchdog_task is not None and not watchdog_task.done():
|
|
147
|
+
watchdog_task.cancel()
|
|
148
|
+
try:
|
|
149
|
+
await watchdog_task
|
|
150
|
+
except asyncio.CancelledError:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
writer.close()
|
|
154
|
+
try:
|
|
155
|
+
await writer.wait_closed()
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
logger.info("Disconnected: %s", addr)
|
|
159
|
+
|
|
160
|
+
async def serve(self) -> None:
|
|
161
|
+
"""Start the server and serve forever."""
|
|
162
|
+
if self._handler is None:
|
|
163
|
+
raise RuntimeError("No session handler registered. Use @server.on_session")
|
|
164
|
+
|
|
165
|
+
srv = await asyncio.start_server(
|
|
166
|
+
self._handle_client,
|
|
167
|
+
self._host,
|
|
168
|
+
self._port,
|
|
169
|
+
)
|
|
170
|
+
addr = srv.sockets[0].getsockname()
|
|
171
|
+
logger.info("Listening on %s:%d", addr[0], addr[1])
|
|
172
|
+
|
|
173
|
+
async with srv:
|
|
174
|
+
await srv.serve_forever()
|
usmp/_session.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# src/usmp/_session.py
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from .types import PacketType, SessionInfo, USMP_MAGIC, USMP_VERSION
|
|
5
|
+
from ._frame import read_frame, write_frame
|
|
6
|
+
from ._crypto import encrypt, decrypt
|
|
7
|
+
from .errors import SequenceError, ConnectionClosedError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class USMPSession:
|
|
11
|
+
"""
|
|
12
|
+
Represents an established USMP session.
|
|
13
|
+
Handles encrypted send/recv with sequence number tracking.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
reader,
|
|
19
|
+
writer,
|
|
20
|
+
info: SessionInfo,
|
|
21
|
+
):
|
|
22
|
+
self._reader = reader
|
|
23
|
+
self._writer = writer
|
|
24
|
+
self._info = info
|
|
25
|
+
self._last_recv: float = time.monotonic() # updated on every inbound frame
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def device_id(self) -> str:
|
|
29
|
+
return self._info.device_id_str
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def session_id(self) -> str:
|
|
33
|
+
return self._info.session_id_str
|
|
34
|
+
|
|
35
|
+
async def send(self, data: bytes) -> None:
|
|
36
|
+
"""Encrypt and send a DATA frame."""
|
|
37
|
+
seq = self._info.tx_seq
|
|
38
|
+
ciphertext = encrypt(
|
|
39
|
+
key=self._info.session_key,
|
|
40
|
+
seq=seq,
|
|
41
|
+
session_id=self._info.session_id,
|
|
42
|
+
type_=int(PacketType.DATA),
|
|
43
|
+
version=USMP_VERSION,
|
|
44
|
+
magic=USMP_MAGIC,
|
|
45
|
+
plaintext=data,
|
|
46
|
+
)
|
|
47
|
+
await write_frame(self._writer, PacketType.DATA, ciphertext, seq=seq)
|
|
48
|
+
self._info.tx_seq += 1
|
|
49
|
+
|
|
50
|
+
async def recv(self) -> bytes:
|
|
51
|
+
"""Receive and decrypt a DATA frame. Transparently handles inbound PING/PONG."""
|
|
52
|
+
frame = await read_frame(self._reader)
|
|
53
|
+
self._last_recv = time.monotonic()
|
|
54
|
+
|
|
55
|
+
if frame.type == PacketType.BYE:
|
|
56
|
+
raise ConnectionClosedError("Remote sent BYE")
|
|
57
|
+
|
|
58
|
+
if frame.type == PacketType.PING:
|
|
59
|
+
self._info.rx_seq += 1 # ← add this
|
|
60
|
+
await self._send_pong()
|
|
61
|
+
return await self.recv()
|
|
62
|
+
|
|
63
|
+
if frame.type == PacketType.PONG:
|
|
64
|
+
self._info.rx_seq += 1
|
|
65
|
+
return await self.recv()
|
|
66
|
+
|
|
67
|
+
if frame.type != PacketType.DATA:
|
|
68
|
+
raise ValueError(f"Unexpected frame type: {frame.type_name()}")
|
|
69
|
+
|
|
70
|
+
if frame.seq != self._info.rx_seq:
|
|
71
|
+
raise SequenceError(
|
|
72
|
+
f"Sequence mismatch: expected {self._info.rx_seq}, got {frame.seq}"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
plaintext = decrypt(
|
|
76
|
+
key=self._info.session_key,
|
|
77
|
+
seq=frame.seq,
|
|
78
|
+
session_id=self._info.session_id,
|
|
79
|
+
type_=int(frame.type),
|
|
80
|
+
version=frame.version,
|
|
81
|
+
magic=frame.magic,
|
|
82
|
+
length=frame.length,
|
|
83
|
+
ciphertext_and_tag=frame.payload,
|
|
84
|
+
)
|
|
85
|
+
self._info.rx_seq += 1
|
|
86
|
+
return plaintext
|
|
87
|
+
|
|
88
|
+
async def ping(self) -> None:
|
|
89
|
+
"""Send a PING frame."""
|
|
90
|
+
seq = self._info.tx_seq
|
|
91
|
+
ciphertext = encrypt(
|
|
92
|
+
key=self._info.session_key,
|
|
93
|
+
seq=seq,
|
|
94
|
+
session_id=self._info.session_id,
|
|
95
|
+
type_=int(PacketType.PING),
|
|
96
|
+
version=USMP_VERSION,
|
|
97
|
+
magic=USMP_MAGIC,
|
|
98
|
+
plaintext=b"",
|
|
99
|
+
)
|
|
100
|
+
await write_frame(self._writer, PacketType.PING, ciphertext, seq=seq)
|
|
101
|
+
self._info.tx_seq += 1
|
|
102
|
+
|
|
103
|
+
async def bye(self) -> None:
|
|
104
|
+
"""Send a BYE frame and close the connection."""
|
|
105
|
+
seq = self._info.tx_seq
|
|
106
|
+
ciphertext = encrypt(
|
|
107
|
+
key=self._info.session_key,
|
|
108
|
+
seq=seq,
|
|
109
|
+
session_id=self._info.session_id,
|
|
110
|
+
type_=int(PacketType.BYE),
|
|
111
|
+
version=USMP_VERSION,
|
|
112
|
+
magic=USMP_MAGIC,
|
|
113
|
+
plaintext=b"",
|
|
114
|
+
)
|
|
115
|
+
await write_frame(self._writer, PacketType.BYE, ciphertext, seq=seq)
|
|
116
|
+
self._info.tx_seq += 1
|
|
117
|
+
self._writer.close()
|
|
118
|
+
|
|
119
|
+
async def _send_pong(self) -> None:
|
|
120
|
+
seq = self._info.tx_seq
|
|
121
|
+
ciphertext = encrypt(
|
|
122
|
+
key=self._info.session_key,
|
|
123
|
+
seq=seq,
|
|
124
|
+
session_id=self._info.session_id,
|
|
125
|
+
type_=int(PacketType.PONG),
|
|
126
|
+
version=USMP_VERSION,
|
|
127
|
+
magic=USMP_MAGIC,
|
|
128
|
+
plaintext=b"",
|
|
129
|
+
)
|
|
130
|
+
await write_frame(self._writer, PacketType.PONG, ciphertext, seq=seq)
|
|
131
|
+
self._info.tx_seq += 1
|
usmp/errors.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# src/usmp/errors.py
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class USMPError(Exception):
|
|
5
|
+
"""Base exception for all USMP errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FrameError(USMPError):
|
|
9
|
+
"""Malformed or invalid frame."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CRCError(FrameError):
|
|
13
|
+
"""CRC verification failed."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MagicError(FrameError):
|
|
17
|
+
"""Bad magic bytes — not a USMP frame."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VersionError(FrameError):
|
|
21
|
+
"""Unsupported protocol version."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PayloadError(FrameError):
|
|
25
|
+
"""Payload truncated or exceeds maximum size."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HandshakeError(USMPError):
|
|
29
|
+
"""Handshake failed."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthError(HandshakeError):
|
|
33
|
+
"""HMAC verification failed — PSK mismatch or tampered frame."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CryptoError(USMPError):
|
|
37
|
+
"""AES-GCM decryption or tag verification failed."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SequenceError(USMPError):
|
|
41
|
+
"""Sequence number out of order — possible replay attack."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TimeoutError(USMPError):
|
|
45
|
+
"""Handshake or keepalive timeout."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ConnectionClosedError(USMPError):
|
|
49
|
+
"""Connection closed by remote."""
|
usmp/py.typed
ADDED
|
File without changes
|
usmp/types.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# src/usmp/types.py
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PacketType(IntEnum):
|
|
8
|
+
HELLO = 0x01
|
|
9
|
+
CHALLENGE = 0x02
|
|
10
|
+
HELLO_ACK = 0x03
|
|
11
|
+
SESSION_OK = 0x04
|
|
12
|
+
DATA = 0x05
|
|
13
|
+
PING = 0x06
|
|
14
|
+
PONG = 0x07
|
|
15
|
+
BYE = 0x08
|
|
16
|
+
ERROR = 0xFF
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ErrorCode(IntEnum):
|
|
20
|
+
ERR_VERSION = 0x01
|
|
21
|
+
ERR_AUTH = 0x02
|
|
22
|
+
ERR_SEQ = 0x03
|
|
23
|
+
ERR_CRYPTO = 0x04
|
|
24
|
+
ERR_BAD_FRAME = 0x05
|
|
25
|
+
ERR_TIMEOUT = 0x06
|
|
26
|
+
ERR_INTERNAL = 0x07
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Protocol constants
|
|
30
|
+
USMP_MAGIC = 0xABCD
|
|
31
|
+
USMP_VERSION = 0x01
|
|
32
|
+
USMP_HEADER_SIZE = 12 # magic(2)+ver(1)+type(1)+seq(4)+len(2)+crc(2)
|
|
33
|
+
USMP_MAX_PAYLOAD = 480 # max payload bytes (keeps total frame under 512)
|
|
34
|
+
USMP_TAG_LEN = 16 # AES-GCM tag length
|
|
35
|
+
USMP_NONCE_LEN = 32 # handshake nonce length
|
|
36
|
+
USMP_DEVICE_ID_LEN = 6 # MAC address length
|
|
37
|
+
USMP_PUB_KEY_LEN = 32 # X25519 public key length
|
|
38
|
+
USMP_HMAC_LEN = 32 # HMAC-SHA256 output length
|
|
39
|
+
USMP_SESSION_ID_LEN = 4 # session ID length
|
|
40
|
+
USMP_SESSION_KEY_LEN = 32 # AES-256 key length
|
|
41
|
+
USMP_GCM_NONCE_LEN = 12 # AES-GCM nonce length
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class USMPFrame:
|
|
46
|
+
magic: int
|
|
47
|
+
version: int
|
|
48
|
+
type: PacketType
|
|
49
|
+
seq: int
|
|
50
|
+
length: int
|
|
51
|
+
crc: int
|
|
52
|
+
payload: bytes
|
|
53
|
+
|
|
54
|
+
def type_name(self) -> str:
|
|
55
|
+
try:
|
|
56
|
+
return PacketType(self.type).name
|
|
57
|
+
except ValueError:
|
|
58
|
+
return f"UNKNOWN(0x{self.type:02X})"
|
|
59
|
+
|
|
60
|
+
def __str__(self) -> str:
|
|
61
|
+
return (
|
|
62
|
+
f"USMPFrame("
|
|
63
|
+
f"type={self.type_name()}, "
|
|
64
|
+
f"seq={self.seq}, "
|
|
65
|
+
f"version={self.version}, "
|
|
66
|
+
f"length={self.length}, "
|
|
67
|
+
f"crc=0x{self.crc:04X}, "
|
|
68
|
+
f"payload={self.payload.hex()}"
|
|
69
|
+
f")"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class SessionInfo:
|
|
75
|
+
device_id: bytes
|
|
76
|
+
session_id: bytes
|
|
77
|
+
session_key: bytes
|
|
78
|
+
tx_seq: int = 0
|
|
79
|
+
rx_seq: int = 0
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def device_id_str(self) -> str:
|
|
83
|
+
return self.device_id.hex(":")
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def session_id_str(self) -> str:
|
|
87
|
+
return self.session_id.hex()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: usmp
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: USMP — Unified Secure Multi-transport Protocol. Secure, encrypted device communication for ESP32, Arduino and IoT.
|
|
5
|
+
Author: winterx64
|
|
6
|
+
Author-email: winterx64 <itswinterx64@gmail.com>
|
|
7
|
+
Requires-Dist: cryptography>=42.0.0
|
|
8
|
+
Requires-Dist: pytest>=8.0.0 ; extra == 'dev'
|
|
9
|
+
Requires-Dist: pytest-asyncio>=0.23.0 ; extra == 'dev'
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Project-URL: Homepage, https://github.com/MetaLoomLabs/usmp
|
|
12
|
+
Project-URL: Repository, https://github.com/MetaLoomLabs/usmp
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# USMP — Unified Secure Multi-transport Protocol
|
|
17
|
+
|
|
18
|
+
Secure, encrypted communication for ESP32, Arduino, and IoT devices.
|
|
19
|
+
|
|
20
|
+
USMP sits between raw TCP (no security) and full TLS (too heavy for microcontrollers) — giving any constrained device a fully encrypted, mutually authenticated session in three function calls.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
pip install usmp
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## What it gives you
|
|
27
|
+
|
|
28
|
+
- **Mutual authentication** — both device and server verify each other via HMAC-SHA256 + PSK
|
|
29
|
+
- **Forward secrecy** — X25519 ephemeral key exchange, new keys every session
|
|
30
|
+
- **Encryption** — AES-256-GCM, mandatory, no plaintext mode
|
|
31
|
+
- **Replay protection** — monotonic sequence numbers
|
|
32
|
+
|
|
33
|
+
## Quickstart
|
|
34
|
+
|
|
35
|
+
### Server
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import asyncio
|
|
39
|
+
from usmp import USMPServer, USMPSession, ConnectionClosedError
|
|
40
|
+
|
|
41
|
+
server = USMPServer(host="0.0.0.0", port=9000, psk=b"your-psk-here")
|
|
42
|
+
|
|
43
|
+
@server.on_session
|
|
44
|
+
async def handle(session: USMPSession):
|
|
45
|
+
print(f"Device connected: {session.device_id}")
|
|
46
|
+
try:
|
|
47
|
+
while True:
|
|
48
|
+
data = await session.recv()
|
|
49
|
+
print(f"RX: {data}")
|
|
50
|
+
await session.send(b"got it")
|
|
51
|
+
except ConnectionClosedError:
|
|
52
|
+
print(f"Device disconnected: {session.device_id}")
|
|
53
|
+
|
|
54
|
+
asyncio.run(server.serve())
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Client (Python)
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import asyncio
|
|
61
|
+
from usmp import USMPClient, ConnectionClosedError
|
|
62
|
+
|
|
63
|
+
async def main():
|
|
64
|
+
client = USMPClient(host="127.0.0.1", port=9000, psk=b"your-psk-here")
|
|
65
|
+
async with client.connect() as session:
|
|
66
|
+
await session.send(b"hello")
|
|
67
|
+
reply = await session.recv()
|
|
68
|
+
print(f"RX: {reply}")
|
|
69
|
+
|
|
70
|
+
asyncio.run(main())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Client (ESP32 / Arduino)
|
|
74
|
+
|
|
75
|
+
```cpp
|
|
76
|
+
#include <USMP.h>
|
|
77
|
+
|
|
78
|
+
USMPClient usmp("your-psk-here");
|
|
79
|
+
|
|
80
|
+
void setup() {
|
|
81
|
+
usmp.begin(USMP::TCP("192.168.1.100").wifi("SSID", "password"));
|
|
82
|
+
usmp.send("hello from esp32");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
void loop() {
|
|
86
|
+
usmp.maintain(); // keepalive + reconnect
|
|
87
|
+
|
|
88
|
+
if (usmp.available()) {
|
|
89
|
+
Serial.println(usmp.read());
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Protocol overview
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
Device Server
|
|
98
|
+
| |
|
|
99
|
+
|-- HELLO (device_id, pub_C) -->|
|
|
100
|
+
|<- CHALLENGE (nonce, pub_S) ---|
|
|
101
|
+
|-- HELLO_ACK (HMAC) ---------->|
|
|
102
|
+
|<- SESSION_OK (session_id) ----|
|
|
103
|
+
| |
|
|
104
|
+
|== AES-256-GCM encrypted ======|
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
4-step handshake, then every frame is AES-256-GCM encrypted with a session key derived via X25519 + HKDF-SHA256.
|
|
108
|
+
|
|
109
|
+
## Installation
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
pip install usmp
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Requires Python 3.11+.
|
|
116
|
+
|
|
117
|
+
## ESP32 / Arduino library
|
|
118
|
+
|
|
119
|
+
The Arduino library and ESP-IDF component are available at [github.com/MetaLoomLabs/usmp](https://github.com/MetaLoomLabs/usmp).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
usmp/__init__.py,sha256=WLOs7KnzaTRgjJVrRlVtiTdAc-P89g1K99WOB5TrKNw,1106
|
|
2
|
+
usmp/_client.py,sha256=kZeBuOxW2VZ73NSnfjf6p5Hu84_39XL0srNKHEwsB6U,2151
|
|
3
|
+
usmp/_crypto.py,sha256=LbISd6QOtZX3kJdudP3b2pRbU7RLGyHeKYqcWQ2QUS4,3300
|
|
4
|
+
usmp/_frame.py,sha256=poaKBJuKLtANyp2Gty0rlX5qEs57RCHHI8U1fStcaXM,4042
|
|
5
|
+
usmp/_handshake.py,sha256=PnC1o1UdRfcpIl6HFdBNoMm1FNn5LbTtO1jeBjjSsSw,6837
|
|
6
|
+
usmp/_server.py,sha256=IUVTEHPqmW3LEaX4LLSXL_UlEMYGeUTlIXcTpkmbCuE,6279
|
|
7
|
+
usmp/_session.py,sha256=PnmjyYYg_xguIoJPYdQPEwscrhekea8_MZEZH6AseOc,4242
|
|
8
|
+
usmp/errors.py,sha256=tKZM6ZFmTE1o0MMsyI1DA3kFqeLkAn2HMzbT0ASqHK4,1036
|
|
9
|
+
usmp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
usmp/types.py,sha256=jLLyWiLnT_o6Na8mnlf-X5HOJdIpRvS6EYHfmj5K-VI,2081
|
|
11
|
+
usmp-0.2.0.dist-info/WHEEL,sha256=wXwAVsgVaOZ_pwDFqQm5Rd6PID-Fc74nkLc8X8gHiDo,81
|
|
12
|
+
usmp-0.2.0.dist-info/METADATA,sha256=fps3kV4Toirnl_LlVfnCfrkg5gjrf_ojw-w4vERaTqg,3297
|
|
13
|
+
usmp-0.2.0.dist-info/RECORD,,
|