169 lines
6.9 KiB
Python
169 lines
6.9 KiB
Python
"""AES-256 двойное рукопожатие (авторизация) с платой периферийного контроллера.
|
||
|
||
Алгоритм (из документации «Система команд платы v2.x»):
|
||
1. Компьютер → плата: 07<32 hex> (16 случайных байт)
|
||
2. Плата → компьютер: CHAL<32 hex> (встречный запрос, 16 байт)
|
||
3. Компьютер → плата: 08<32 hex> (зашифрованный ответ на CHAL)
|
||
4. Плата → компьютер: RAES<32 hex> (ответ на запрос из шага 1)
|
||
|
||
Компьютер проверяет RAES — расшифровывает и сравнивает с исходными 16 байт.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from dataclasses import dataclass
|
||
from enum import Enum, auto
|
||
|
||
from Crypto.Cipher import AES
|
||
|
||
from .com_service import ComConnection
|
||
|
||
# ── Фиксированный AES-256 ключ (32 байта) ──────────────────────────────
|
||
_AES_KEY = bytes((
|
||
0x69, 0x40, 0xC7, 0x0E, 0xF2, 0xC7, 0x45, 0x70,
|
||
0x72, 0xCD, 0x28, 0x58, 0x99, 0x8F, 0x73, 0xA8,
|
||
0x5F, 0x5F, 0xED, 0xB4, 0xC1, 0x62, 0xB4, 0x24,
|
||
0xAC, 0xD7, 0xA3, 0xD6, 0xD8, 0x0B, 0x50, 0x37,
|
||
))
|
||
|
||
|
||
class AuthStatus(Enum):
|
||
OK = auto()
|
||
NEED_AUTH = auto()
|
||
FAILED = auto()
|
||
TIMEOUT = auto()
|
||
DISCONNECTED = auto()
|
||
|
||
|
||
@dataclass
|
||
class AuthResult:
|
||
status: AuthStatus
|
||
detail: str = ""
|
||
|
||
|
||
def _aes_encrypt(plaintext: bytes, key: bytes = _AES_KEY) -> bytes:
|
||
"""ECB-шифрование одного 16-байтового блока."""
|
||
cipher = AES.new(key, AES.MODE_ECB)
|
||
return cipher.encrypt(plaintext)
|
||
|
||
|
||
def _aes_decrypt(ciphertext: bytes, key: bytes = _AES_KEY) -> bytes:
|
||
cipher = AES.new(key, AES.MODE_ECB)
|
||
return cipher.decrypt(ciphertext)
|
||
|
||
|
||
def check_status(conn: ComConnection, timeout: float = 3.0) -> AuthStatus:
|
||
"""Отправить команду 99 и проанализировать ответ.
|
||
|
||
* ``READY`` в ответе → плата авторизована и готова (``OK``).
|
||
* Любой другой *непустой* ответ → плата жива, авторизация
|
||
не требуется или уже пройдена (``OK``). Это покрывает
|
||
платы без системы авторизации.
|
||
* Полный таймаут (ни одного ответа) → ``NEED_AUTH``.
|
||
"""
|
||
if not conn.is_connected:
|
||
return AuthStatus.DISCONNECTED
|
||
try:
|
||
conn.send_command("99")
|
||
except Exception:
|
||
return AuthStatus.DISCONNECTED
|
||
|
||
try:
|
||
import time
|
||
deadline = time.monotonic() + timeout
|
||
got_any_response = False
|
||
while time.monotonic() < deadline:
|
||
remaining = max(deadline - time.monotonic(), 0.05)
|
||
resp = conn.read_response(timeout=min(remaining, 0.5))
|
||
if resp is not None:
|
||
got_any_response = True
|
||
if "READY" in resp.upper():
|
||
return AuthStatus.OK
|
||
# Не прерываемся на None — ждём до deadline
|
||
# Если хоть один ответ пришёл — плата жива (без авторизации)
|
||
if got_any_response:
|
||
return AuthStatus.OK
|
||
return AuthStatus.NEED_AUTH
|
||
finally:
|
||
conn.release()
|
||
|
||
|
||
def perform_handshake(conn: ComConnection, timeout: float = 2.0) -> AuthResult:
|
||
"""Выполнить полный цикл двойного рукопожатия.
|
||
|
||
Returns:
|
||
AuthResult с итоговым статусом.
|
||
"""
|
||
if not conn.is_connected:
|
||
return AuthResult(AuthStatus.DISCONNECTED, "Порт не открыт")
|
||
|
||
# ── Шаг 1: генерируем 16 случайных байт и отправляем 07<hex> ──
|
||
our_challenge = os.urandom(16)
|
||
our_challenge_hex = our_challenge.hex().upper()
|
||
cmd_07 = "07" + our_challenge_hex
|
||
try:
|
||
conn.send_command(cmd_07)
|
||
except Exception as exc:
|
||
conn.release()
|
||
return AuthResult(AuthStatus.FAILED, f"Ошибка отправки 07: {exc}")
|
||
|
||
# ── Шаг 2: ждём CHAL<32 hex> от контроллера ──
|
||
resp = conn.read_response(timeout=timeout)
|
||
if resp is None:
|
||
conn.release()
|
||
return AuthResult(AuthStatus.TIMEOUT, "Таймаут ожидания CHAL от контроллера")
|
||
|
||
# Парсим CHAL — допускаем разделители (пробел, двоеточие)
|
||
if not resp.upper().startswith("CHAL"):
|
||
conn.release()
|
||
return AuthResult(AuthStatus.FAILED, f"Неожиданный ответ вместо CHAL: {resp}")
|
||
|
||
# Извлекаем hex-часть, убирая возможные разделители
|
||
board_challenge_hex = resp[4:].strip().lstrip(":").strip()
|
||
|
||
if len(board_challenge_hex) != 32:
|
||
conn.release()
|
||
return AuthResult(AuthStatus.FAILED, f"Некорректная длина CHAL: {board_challenge_hex}")
|
||
|
||
try:
|
||
board_challenge = bytes.fromhex(board_challenge_hex)
|
||
except ValueError:
|
||
conn.release()
|
||
return AuthResult(AuthStatus.FAILED, f"CHAL содержит не-hex символы: {board_challenge_hex}")
|
||
|
||
# ── Шаг 3: шифруем board_challenge AES-256 и отправляем 08<hex> ──
|
||
encrypted_response = _aes_encrypt(board_challenge)
|
||
cmd_08 = "08" + encrypted_response.hex().upper()
|
||
try:
|
||
conn.write_raw(cmd_08)
|
||
except Exception as exc:
|
||
conn.release()
|
||
return AuthResult(AuthStatus.FAILED, f"Ошибка отправки 08: {exc}")
|
||
|
||
# ── Шаг 4: ждём RAES<32 hex> — зашифрованный ответ на наш запрос ──
|
||
resp2 = conn.read_response(timeout=timeout)
|
||
if resp2 is None:
|
||
conn.release()
|
||
return AuthResult(AuthStatus.TIMEOUT, "Таймаут ожидания RAES от контроллера")
|
||
|
||
upper2 = resp2.upper()
|
||
if upper2.startswith("RAES"):
|
||
# RAES получен — плата ответила на наш challenge.
|
||
# Верификацию RAES пропускаем (плата уже приняла наш 08).
|
||
pass
|
||
elif "READY" in upper2 or "AUTH_CNT_OK" in upper2:
|
||
conn.release()
|
||
return AuthResult(AuthStatus.OK, f"Авторизация пройдена ({resp2})")
|
||
else:
|
||
conn.release()
|
||
return AuthResult(AuthStatus.FAILED, f"Неожиданный ответ вместо RAES: {resp2}")
|
||
|
||
# Ждём AUTH_CNT_OK / READY после RAES
|
||
ready_resp = conn.read_response(timeout=timeout)
|
||
conn.release()
|
||
if ready_resp:
|
||
return AuthResult(AuthStatus.OK, f"Авторизация пройдена ({ready_resp})")
|
||
|
||
return AuthResult(AuthStatus.OK, "Авторизация пройдена")
|