plate-config-app/app/services/auth_service.py

169 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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, "Авторизация пройдена")