plate-config-app/app/services/auth_service.py
Danil Iarosh add8aff0af feat: release version 2.0.0
- Добавлена обратная совместимость с прошивками, содержащими маркер no_auth.

- Повторная авторизация после команды 04 и успешного обновления прошивки приведена к требуемому порядку.

- В терминал команд добавлена ручная кнопка авторизации.

- Реализовано OTA-обновление приложения с загрузкой платформенного артефакта, безопасной заменой и перезапуском.

- Проверка обновлений учитывает только более новые версии по схеме major, minor и patch.

- Обновлены имена Windows и Linux сборок, документация и тестовое покрытие.
2026-09-16 10:01:35 +03:00

307 lines
12 KiB
Python
Raw Permalink 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 авторизация платы периферийного контроллера.
Стартовая последовательность:
1. Плата → компьютер: periphery controller ver_<версия и комментарий>
2. Компьютер ждёт 2000 мс.
3. Компьютер → плата: 07<32 hex> (16 случайных байт)
4. Плата → компьютер: CHAL<32 hex> (встречный запрос, 16 байт)
5. Компьютер → плата: 08<32 hex> (зашифрованный ответ на CHAL)
6. Плата → компьютер: RAES<32 hex> (ответ на запрос из шага 3)
7. Плата → компьютер: READY!
Авторизация считается завершённой только после точной строки ``READY!``.
"""
from __future__ import annotations
import hmac
import os
import time
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum, auto
from Crypto.Cipher import AES
from .com_service import ComConnection
STARTUP_BANNER_PREFIX = "periphery controller ver_"
NO_AUTH_MARKER = "no_auth"
STARTUP_DELAY_SECONDS = 2.0
STARTUP_BANNER_TIMEOUT_SECONDS = 20.0
READY_TIMEOUT_SECONDS = 5.0
# Ключ хранится в том же обфусцированном виде, что и в рабочем клиенте.
_AES_KEY_MASK = 0x37
_AES_OBFUSCATED_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,
))
_AES_KEY = bytes(value ^ _AES_KEY_MASK for value in _AES_OBFUSCATED_KEY)
class AuthStatus(Enum):
OK = auto()
NEED_AUTH = auto()
FAILED = auto()
TIMEOUT = auto()
DISCONNECTED = auto()
@dataclass
class AuthResult:
status: AuthStatus
detail: str = ""
startup_ready: bool = False
firmware_version: str | None = None
auth_skipped: bool = False
def firmware_requires_auth(version: str) -> bool:
"""Определить режим авторизации по строке версии прошивки."""
return NO_AUTH_MARKER not in version.casefold()
def _remember_firmware(conn: ComConnection, banner: str) -> str:
version = banner[len(STARTUP_BANNER_PREFIX):].strip()
conn.set_firmware_version(
version,
auth_required=firmware_requires_auth(version),
)
return version
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 _read_until(
conn: ComConnection,
predicate: Callable[[str], bool],
timeout: float,
) -> str | None:
"""Читать полные строки до совпадения или истечения общего таймаута."""
deadline = time.monotonic() + timeout
while conn.is_connected:
remaining = deadline - time.monotonic()
if remaining <= 0:
return None
line = conn.read_response(timeout=remaining)
if line is None:
return None
if predicate(line.strip()):
return line.strip()
return None
def wait_for_startup_banner(
conn: ComConnection,
timeout: float = STARTUP_BANNER_TIMEOUT_SECONDS,
) -> str | None:
"""Дождаться ``periphery controller ver_<...>`` с непустым суффиксом."""
def is_banner(line: str) -> bool:
if not line.startswith(STARTUP_BANNER_PREFIX):
return False
return bool(line[len(STARTUP_BANNER_PREFIX):].strip())
return _read_until(conn, is_banner, timeout)
def check_status(conn: ComConnection, timeout: float = 3.0) -> AuthStatus:
"""Отправить команду 99 и проанализировать ответ.
Для прошивки с авторизацией только точная строка ``READY!`` означает
готовность. Для обнаруженной прошивки ``no_auth`` достаточно любого
непустого ответа: отсутствие ответа считается потерей связи, а не
запросом авторизации.
"""
if not conn.is_connected:
return AuthStatus.DISCONNECTED
try:
conn.send_command("99")
except Exception:
return AuthStatus.DISCONNECTED
try:
if conn.auth_required is False:
response = _read_until(conn, bool, timeout)
return AuthStatus.OK if response is not None else AuthStatus.DISCONNECTED
ready = _read_until(conn, lambda line: line == "READY!", timeout)
return AuthStatus.OK if ready is not None else AuthStatus.NEED_AUTH
finally:
conn.release()
def perform_required_handshake(
conn: ComConnection,
timeout: float = 2.0,
ready_timeout: float = READY_TIMEOUT_SECONDS,
) -> AuthResult:
"""Выполнить handshake только если он требуется текущей прошивке."""
if not conn.is_connected:
return AuthResult(AuthStatus.DISCONNECTED, "Порт не открыт")
if conn.auth_required is False:
version = conn.firmware_version
detail = "Авторизация не требуется: обнаружена прошивка no_auth"
if version:
detail = f"Прошивка {version}: авторизация не требуется"
return AuthResult(
AuthStatus.OK,
detail,
firmware_version=version,
auth_skipped=True,
)
return perform_handshake(
conn,
timeout=timeout,
ready_timeout=ready_timeout,
)
def perform_handshake(
conn: ComConnection,
timeout: float = 2.0,
ready_timeout: float = READY_TIMEOUT_SECONDS,
) -> 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}")
try:
# ── Шаг 2: ждём CHAL<32 hex> от контроллера ──
resp = _read_until(conn, lambda line: line.upper().startswith("CHAL"), timeout)
if resp is None:
return AuthResult(AuthStatus.TIMEOUT, "Таймаут ожидания CHAL от контроллера")
# Парсим CHAL — допускаем разделители (пробел, двоеточие)
board_challenge_hex = resp[4:].strip().lstrip(":").strip()
if len(board_challenge_hex) != 32:
return AuthResult(
AuthStatus.FAILED,
f"Некорректная длина CHAL: {board_challenge_hex}",
)
try:
board_challenge = bytes.fromhex(board_challenge_hex)
except ValueError:
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:
return AuthResult(AuthStatus.FAILED, f"Ошибка отправки 08: {exc}")
# ── Шаг 4: проверяем доказательство контроллера RAES ──
raes = _read_until(conn, lambda line: line.upper().startswith("RAES"), timeout)
if raes is None:
return AuthResult(AuthStatus.TIMEOUT, "Таймаут ожидания RAES от контроллера")
controller_proof_hex = raes[4:].strip().lstrip(":").strip()
try:
controller_proof = bytes.fromhex(controller_proof_hex)
except ValueError:
return AuthResult(AuthStatus.FAILED, "RAES содержит некорректные hex-данные")
expected_proof = _aes_encrypt(our_challenge)
if len(controller_proof) != 16 or not hmac.compare_digest(
controller_proof,
expected_proof,
):
return AuthResult(AuthStatus.FAILED, "Проверка RAES контроллера не пройдена")
# AUTH_PC_OK и другие промежуточные строки игнорируются. Успех —
# только точная отдельная строка READY! после RAES.
ready = _read_until(conn, lambda line: line == "READY!", ready_timeout)
if ready is None:
return AuthResult(AuthStatus.TIMEOUT, "Таймаут ожидания READY! от контроллера")
return AuthResult(AuthStatus.OK, "Авторизация подтверждена строкой READY!")
finally:
conn.release()
def perform_startup_handshake(
conn: ComConnection,
*,
banner_timeout: float = STARTUP_BANNER_TIMEOUT_SECONDS,
startup_delay: float = STARTUP_DELAY_SECONDS,
handshake_timeout: float = 2.0,
ready_timeout: float = READY_TIMEOUT_SECONDS,
sleep_fn: Callable[[float], None] = time.sleep,
) -> AuthResult:
"""Дождаться загрузки и выполнить handshake, если он требуется."""
if not conn.is_connected:
return AuthResult(AuthStatus.DISCONNECTED, "Порт не открыт")
try:
banner = wait_for_startup_banner(conn, timeout=banner_timeout)
except Exception as exc:
return AuthResult(AuthStatus.FAILED, f"Ошибка ожидания строки запуска: {exc}")
if banner is None:
return AuthResult(
AuthStatus.TIMEOUT,
f"Таймаут ожидания {STARTUP_BANNER_PREFIX}<версия>",
)
version = _remember_firmware(conn, banner)
sleep_fn(startup_delay)
if not conn.is_connected:
return AuthResult(
AuthStatus.DISCONNECTED,
"Порт закрыт до проверки режима авторизации",
startup_ready=True,
firmware_version=version,
)
if not firmware_requires_auth(version):
conn.release()
return AuthResult(
AuthStatus.OK,
f"{banner}\nАвторизация не требуется для прошивки no_auth",
startup_ready=True,
firmware_version=version,
auth_skipped=True,
)
result = perform_handshake(
conn,
timeout=handshake_timeout,
ready_timeout=ready_timeout,
)
result.startup_ready = True
result.firmware_version = version
if result.status == AuthStatus.OK:
result.detail = f"{banner}\n{result.detail}"
return result