From ca42cf53baa4bbedc77d3bc11547a2a4c83cca57 Mon Sep 17 00:00:00 2001 From: sega Date: Mon, 14 Sep 2026 16:34:28 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D1=81=D0=B8=D0=BB=D1=8C=D1=82=D0=B5=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8E=20=D0=BF=D0=BB=D0=B0=D1=82=20=D0=B8=20=D0=B2=D0=BE=D1=81?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20COM-=D1=81=D0=BE=D0=B5=D0=B4=D0=B8=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Scripts/Common/GameManager.cs | 7 +- .../Input/ComSystem/BoardAuthentication.cs | 116 ++++++++++ .../ComSystem/BoardAuthentication.cs.meta | 2 + .../Scripts/Input/ComSystem/ComPortScanner.cs | 61 +++-- Assets/Scripts/Input/ComSystem/ComWrapper.cs | 208 +++++++++++------- 5 files changed, 288 insertions(+), 106 deletions(-) create mode 100644 Assets/Scripts/Input/ComSystem/BoardAuthentication.cs create mode 100644 Assets/Scripts/Input/ComSystem/BoardAuthentication.cs.meta diff --git a/Assets/Scripts/Common/GameManager.cs b/Assets/Scripts/Common/GameManager.cs index e33d317..15d7993 100644 --- a/Assets/Scripts/Common/GameManager.cs +++ b/Assets/Scripts/Common/GameManager.cs @@ -145,7 +145,11 @@ public class GameManager : MonoBehaviour } } - private void OnApplicationQuit() => ComNativeWrapper.Serial_CloseAll(); + private void OnApplicationQuit() + { + ComWrapper.Shutdown(); + ComNativeWrapper.Serial_CloseAll(); + } private void Update() { @@ -172,6 +176,7 @@ public class GameManager : MonoBehaviour RefundEntryIfAvailable(); _isLeavingGameplay = true; CancelPrizeFlow(); + VitrinaView.Instance?.Deactivate(); ControlsPerformer.Instance.SetControlable(null); timerManager.HideBars(); VideoUtility.CrossFadeToMenu(); diff --git a/Assets/Scripts/Input/ComSystem/BoardAuthentication.cs b/Assets/Scripts/Input/ComSystem/BoardAuthentication.cs new file mode 100644 index 0000000..b8d5d30 --- /dev/null +++ b/Assets/Scripts/Input/ComSystem/BoardAuthentication.cs @@ -0,0 +1,116 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +public static class BoardAuthentication +{ + private const int _blockSizeBytes = 16; + private const int _hexBlockLength = _blockSizeBytes * 2; + + private static readonly byte[] _aesObfuscatedKey = + { + 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 + }; + + public static string CreateChallengeHex() + { + byte[] challenge = new byte[_blockSizeBytes]; + using RandomNumberGenerator generator = RandomNumberGenerator.Create(); + generator.GetBytes(challenge); + return ToHex(challenge); + } + + public static string EncryptHexBlock(string plainHex) + { + byte[] plain = ParseHexBlock(plainHex); + + using Aes aes = Aes.Create(); + aes.KeySize = 256; + aes.BlockSize = 128; + aes.Mode = CipherMode.ECB; + aes.Padding = PaddingMode.None; + aes.Key = (byte[])_aesObfuscatedKey.Clone(); + + using ICryptoTransform encryptor = aes.CreateEncryptor(); + byte[] encrypted = encryptor.TransformFinalBlock(plain, 0, plain.Length); + return ToHex(encrypted); + } + + public static bool TryExtractHexPayload(string line, string prefix, out string payload) + { + payload = null; + if (string.IsNullOrEmpty(line) + || string.IsNullOrEmpty(prefix) + || line.Length != prefix.Length + _hexBlockLength + || !line.StartsWith(prefix, StringComparison.Ordinal)) + return false; + + string candidate = line.Substring(prefix.Length); + for (int i = 0; i < candidate.Length; i++) + { + if (!IsHexCharacter(candidate[i])) + return false; + } + + payload = candidate.ToUpperInvariant(); + return true; + } + + public static bool ValidateEncryptedResponse(string challengeHex, string responseHex) + { + if (!TryNormalizeHexBlock(responseHex, out string normalizedResponse)) + return false; + + string expectedResponse = EncryptHexBlock(challengeHex); + int difference = 0; + for (int i = 0; i < expectedResponse.Length; i++) + difference |= expectedResponse[i] ^ normalizedResponse[i]; + + return difference == 0; + } + + private static byte[] ParseHexBlock(string value) + { + if (!TryNormalizeHexBlock(value, out string normalized)) + throw new ArgumentException($"Ожидался HEX-блок длиной {_hexBlockLength} символа.", nameof(value)); + + byte[] bytes = new byte[_blockSizeBytes]; + for (int i = 0; i < bytes.Length; i++) + bytes[i] = Convert.ToByte(normalized.Substring(i * 2, 2), 16); + + return bytes; + } + + private static bool TryNormalizeHexBlock(string value, out string normalized) + { + normalized = null; + if (string.IsNullOrEmpty(value) || value.Length != _hexBlockLength) + return false; + + for (int i = 0; i < value.Length; i++) + { + if (!IsHexCharacter(value[i])) + return false; + } + + normalized = value.ToUpperInvariant(); + return true; + } + + private static bool IsHexCharacter(char value) + => value >= '0' && value <= '9' + || value >= 'A' && value <= 'F' + || value >= 'a' && value <= 'f'; + + private static string ToHex(byte[] bytes) + { + StringBuilder builder = new(bytes.Length * 2); + for (int i = 0; i < bytes.Length; i++) + builder.Append(bytes[i].ToString("X2")); + + return builder.ToString(); + } +} diff --git a/Assets/Scripts/Input/ComSystem/BoardAuthentication.cs.meta b/Assets/Scripts/Input/ComSystem/BoardAuthentication.cs.meta new file mode 100644 index 0000000..293e8a2 --- /dev/null +++ b/Assets/Scripts/Input/ComSystem/BoardAuthentication.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b353d677af907bc47a6706e829da2a34 \ No newline at end of file diff --git a/Assets/Scripts/Input/ComSystem/ComPortScanner.cs b/Assets/Scripts/Input/ComSystem/ComPortScanner.cs index bb83ef4..f688326 100644 --- a/Assets/Scripts/Input/ComSystem/ComPortScanner.cs +++ b/Assets/Scripts/Input/ComSystem/ComPortScanner.cs @@ -15,7 +15,8 @@ public static class ComPortScanner // Команды устройств private const string CmdPrize = "97"; // Для витрины и периферийника2 - private const string CmdControllerV1 = "666"; // Только для периферийника1 + private const string CmdControllerAuth = "07"; + private const int DeviceResponseTimeoutMs = 1000; // Типы устройств public enum DeviceType @@ -27,8 +28,6 @@ public static class ComPortScanner } // Префиксы ответов - private const string ControllerV1ResponsePrefix = "ENC:"; // Периферийник1 на 666 - private const string ControllerV1NotActivatedPrefix = "NOT_ACTIVATED:"; // Периферийник1 на всё остальное private const string RolePrefix = "ROLE :"; // Периферийник2 и витрина на 97 private const string PrizeRoleValue = "vitrina"; // Витрина: ROLE : vitrina @@ -61,19 +60,10 @@ public static class ComPortScanner private static DeviceType CheckPort(string port) { Debug.Log($"Проверка порта {port}"); - // ШАГ 1: Проверяем периферийник1 по команде 666 - string response666 = CheckDevice(port, CmdControllerV1); - if (response666 != null) - { - // Если ответ начинается с "ENC:" - это точно периферийник1 - if (response666.StartsWith(ControllerV1ResponsePrefix) || response666.StartsWith("ALREADY_ACTIVATED")) - { - return DeviceType.ControllerV1; - } - } - - // ШАГ 2: Проверяем по команде 97 (витрина и периферийник2) - string response97 = CheckDevice(port, CmdPrize); + string response97 = CheckDevice( + port, + CmdPrize, + line => line.StartsWith(RolePrefix, StringComparison.Ordinal)); if (response97 != null && response97.StartsWith(RolePrefix)) { Debug.Log($"Role: {response97}"); @@ -89,15 +79,15 @@ public static class ComPortScanner { return DeviceType.ControllerV2; } - } - // ШАГ 3: Дополнительная проверка для неактивированного периферийника1 - // Если устройство ответило на 97 с "NOT_ACTIVATED:" - это периферийник1 - if (response97 != null && response97.StartsWith(ControllerV1NotActivatedPrefix)) - { + string computerChallenge = BoardAuthentication.CreateChallengeHex(); + string authResponse = CheckDevice( + port, + CmdControllerAuth + computerChallenge, + IsAuthenticationResponse); + if (authResponse != null) return DeviceType.ControllerV1; - } return DeviceType.None; } @@ -142,7 +132,7 @@ public static class ComPortScanner /// /// Проверяет устройство на порту и возвращает ответ /// - private static string CheckDevice(string port, string command) + private static string CheckDevice(string port, string command, Func isExpectedResponse) { int handle = ComNativeWrapper.Serial_Open(port, BaudRate, DataBits, Parity, StopBits, 10, WriteTimeoutMs); if (handle <= 0) @@ -161,19 +151,28 @@ public static class ComPortScanner } byte[] buffer = new byte[1024]; - int bytesRead = ComNativeWrapper.Serial_ReadLineUTF8_bytes(handle, buffer, buffer.Length); - - if (bytesRead <= 0) + long deadline = DateTime.UtcNow.Ticks + + DeviceResponseTimeoutMs * TimeSpan.TicksPerMillisecond; + while (DateTime.UtcNow.Ticks < deadline) { - return null; + int bytesRead = ComNativeWrapper.Serial_ReadLineUTF8_bytes(handle, buffer, buffer.Length); + if (bytesRead <= 0) + continue; + + string line = Encoding.UTF8.GetString(buffer, 0, bytesRead).Trim(); + if (!string.IsNullOrEmpty(line) && isExpectedResponse(line)) + return line; } - string line = Encoding.UTF8.GetString(buffer, 0, bytesRead).Trim(); - return string.IsNullOrEmpty(line) ? null : line; + return null; } finally { - ComNativeWrapper.Serial_CloseAll(); + ComNativeWrapper.Serial_Close(handle); } } -} \ No newline at end of file + + private static bool IsAuthenticationResponse(string line) + => BoardAuthentication.TryExtractHexPayload(line, "CHAL", out _) + || BoardAuthentication.TryExtractHexPayload(line, "RAES", out _); +} diff --git a/Assets/Scripts/Input/ComSystem/ComWrapper.cs b/Assets/Scripts/Input/ComSystem/ComWrapper.cs index f62efb7..810fc34 100644 --- a/Assets/Scripts/Input/ComSystem/ComWrapper.cs +++ b/Assets/Scripts/Input/ComSystem/ComWrapper.cs @@ -16,25 +16,37 @@ public static class ComWrapper public static event Action PaymentReceived; public static event Action InputRecieved; - private const string REQUEST_INPUT_COMMAND = "91"; - private const string REQUEST_ENCRYPTED_COMMAND = "666"; - private const string REQUEST_CONFIRMATION_COMMAND = "777"; + private const string _requestInputCommand = "91"; + private const string _requestAuthCommand = "07"; + private const string _responseAuthCommand = "08"; + private const string _challengePrefix = "CHAL"; + private const string _encryptedResponsePrefix = "RAES"; + private const string _readyResponse = "READY!"; + private const string _cabinetLightingOnCommand = "856001"; + private const string _cabinetLightingOffCommand = "856000"; + private const int _authStepTimeoutMs = 3000; + private const int _authReadyTimeoutMs = 5000; + private const int _inputIdleBeforePingMs = 60_000; + private const int _keepAlivePollMs = 5000; + private const int _reconnectRetryMs = 10_000; private static CancellationTokenSource _lifetimeCts; private static Thread _readerThread; private static CancellationTokenSource _readerCts; private static int h; - public static int BoardVersion { get; private set; } - private static ILogger logger; - private static string[] AvailablePorts => ComNativeWrapper.ListPorts(); + private static int _lastInputTick; + private static string _controllerPort; + private static volatile bool _isAuthorized; - private static int _lastInputTick = 0; - private const int INPUT_IDLE_BEFORE_PING_MS = 60_000;//1 минута + public static int BoardVersion { get; private set; } + + private static ILogger logger; public static void Initialize(string port, int boardVersion)//Добавить логгер в конструктор { BoardVersion = boardVersion; + _controllerPort = port; if (_lifetimeCts != null) return; OpenPort(port); @@ -45,7 +57,7 @@ public static class ComWrapper } UnityMainThreadDispatcher.Initialize(); _lifetimeCts = new CancellationTokenSource(); - + MarkInputActivity(); StartReaderThread(); _ = SetupBoardAsync(_lifetimeCts.Token); @@ -130,13 +142,19 @@ public static class ComWrapper if (line == "COIN") { + if (!_isAuthorized) + return; + PaymentReceived?.Invoke(); Send("10"); } else if (line.StartsWith("INPUT")) { - Volatile.Write(ref _lastInputTick, Environment.TickCount); + if (!_isAuthorized) + return; + + MarkInputActivity(); string rawInputString = line.Substring(5); string inputString = BoardVersion == 2 ? DecimalToBinary(rawInputString) : rawInputString; @@ -215,8 +233,8 @@ public static class ComWrapper static Task WaitForPrefixAsync(string prefix, int timeoutMs, CancellationToken ct) => WaitForAsync(s => s.StartsWith(prefix, StringComparison.Ordinal), timeoutMs, ct); - static Task WaitForContainsAsync(string prefix, int timeoutMs, CancellationToken ct) - => WaitForAsync(s => s.IndexOf(prefix, StringComparison.Ordinal) >= 0, timeoutMs, ct); + static Task WaitForExactAsync(string expected, int timeoutMs, CancellationToken ct) + => WaitForAsync(s => string.Equals(s, expected, StringComparison.Ordinal), timeoutMs, ct); static void OnLine(string line) { @@ -249,18 +267,12 @@ public static class ComWrapper private static async Task SetupBoardAsync(CancellationToken ct) { - bool auth; - if (BoardVersion == 1) - auth = await BoardAuthAsync(ct); - else - auth = true; + _isAuthorized = await BoardAuthAsync(ct); - if (!auth) - { + if (!_isAuthorized) Log("Авторизация на плате не пройдена."); - UnityMainThreadDispatcher.Instance.Enqueue(() => Application.Quit()); - return; - } + else + HandleAuthorizationSucceeded(); _ = StartKeepAliveProcessAsync(ct); } @@ -270,7 +282,6 @@ public static class ComWrapper { Log("Инициализация порта"); - ComNativeWrapper.Serial_CloseAll(); Log("Открываю порт: " + port); h = ComNativeWrapper.Serial_Open(port, 115200, 8, 0, 1, 4, 10); @@ -351,52 +362,70 @@ public static class ComWrapper private static async Task BoardAuthAsync(CancellationToken ct) { - //Проверяем зашифрована или нет + using CancellationTokenSource authCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + CancellationToken authToken = authCts.Token; + string computerChallenge = BoardAuthentication.CreateChallengeHex(); + try { - Send(REQUEST_INPUT_COMMAND); - var inputLine = await WaitForPrefixAsync("INPUT", 1000, ct); + Task controllerChallengeTask = WaitForAsync( + line => BoardAuthentication.TryExtractHexPayload(line, _challengePrefix, out _), + _authStepTimeoutMs, + authToken); + Send(_requestAuthCommand + computerChallenge); + + string controllerChallengeLine = await controllerChallengeTask; + if (!BoardAuthentication.TryExtractHexPayload( + controllerChallengeLine, + _challengePrefix, + out string controllerChallenge)) + return false; + + Task encryptedResponseTask = WaitForAsync( + line => BoardAuthentication.TryExtractHexPayload(line, _encryptedResponsePrefix, out _), + _authStepTimeoutMs, + authToken); + Task readyTask = WaitForExactAsync( + _readyResponse, + _authReadyTimeoutMs, + authToken); + Send(_responseAuthCommand + BoardAuthentication.EncryptHexBlock(controllerChallenge)); + + string encryptedResponseLine = await encryptedResponseTask; + if (!BoardAuthentication.TryExtractHexPayload( + encryptedResponseLine, + _encryptedResponsePrefix, + out string encryptedResponse) + || !BoardAuthentication.ValidateEncryptedResponse(computerChallenge, encryptedResponse)) + { + Log("Контроллер не прошёл взаимную AES-проверку."); + return false; + } + + await readyTask; + Log("Авторизация платы полностью завершена по READY!."); return true; } - catch (TimeoutException) + catch (OperationCanceledException) when (ct.IsCancellationRequested) { - //Не пришел инпут - Log("Шифрованная плата"); - } - - //Запрашиваем шифрованную строку - string encLine; - try - { - Send(REQUEST_ENCRYPTED_COMMAND); - encLine = await WaitForPrefixAsync("ENC:", 1000, ct); - } - catch - { - Log($"Зашифрованная строка не пришла"); return false; } - - //Расшифровка - var encrypted = encLine.Substring(4); - var decrypted = BoardUtility.DecryptSerial(encrypted); - - //Отправляем расшифрованное на подтверждение - try + catch (Exception exception) { - Send(REQUEST_CONFIRMATION_COMMAND + decrypted); - await WaitForContainsAsync("OK", 1000, ct); - return true; - } - catch - { - Log("Подтверждение не пришло"); + Log($"Ошибка авторизации платы: {exception.Message}"); return false; } + finally + { + authCts.Cancel(); + } } public static void Shutdown() { + if (h > 0) + Send(_cabinetLightingOffCommand); + _lifetimeCts?.Cancel(); _lifetimeCts?.Dispose(); _lifetimeCts = null; @@ -404,6 +433,8 @@ public static class ComWrapper StopReaderThread(); CancelAllWaiters(); ClosePort(); + _isAuthorized = false; + _controllerPort = null; } private static void CancelAllWaiters() @@ -431,7 +462,6 @@ public static class ComWrapper } - //Сделать ожидание простоя около минуты на проверку и переподелючение. private static async Task StartKeepAliveProcessAsync(CancellationToken ct) { try @@ -439,20 +469,32 @@ public static class ComWrapper Log("Запуск асинхронной проверки порта"); while (!ct.IsCancellationRequested) { - if (!IsInputIdleTooLong()) + if (!_isAuthorized) { - await Task.Delay(5000, ct); + Log("Нет авторизованного соединения. Запускаю переподключение."); + await ReinitializePortAsync(ct); + await Task.Delay(_reconnectRetryMs, ct); + continue; + } + + if (!IsInputIdleTooLong()) + { + await Task.Delay(_keepAlivePollMs, ct); continue; } - //Пингуем плату if (!await CheckPortConnectionAsync(ct)) { Log("Плата отвалилась. Запускаю переподключение."); + _isAuthorized = false; await ReinitializePortAsync(ct); } + else + { + MarkInputActivity(); + } - await Task.Delay(10000, ct); + await Task.Delay(_keepAlivePollMs, ct); } } catch (OperationCanceledException) @@ -470,8 +512,9 @@ public static class ComWrapper { try { - Send(REQUEST_INPUT_COMMAND); - await WaitForPrefixAsync("INPUT", 1000, ct); + Task inputResponseTask = WaitForPrefixAsync("INPUT", 1000, ct); + Send(_requestInputCommand); + await inputResponseTask; return true; } catch @@ -480,40 +523,57 @@ public static class ComWrapper } } - //реализовать - private static async Task ReinitializePortAsync(CancellationToken ct) + private static async Task ReinitializePortAsync(CancellationToken ct) { StopReaderThread(); CancelAllWaiters(); ClosePort(); - OpenPort(AvailablePorts[^1]); + if (string.IsNullOrWhiteSpace(_controllerPort)) + { + Log("Не сохранён COM-порт контроллера."); + return false; + } + + OpenPort(_controllerPort); if (h <= 0) { - Log($"Ошибка при переподключении."); - return; + Log("Ошибка при переподключении."); + return false; } - StartReaderThread(); - var auth = await BoardAuthAsync(ct); - if (!auth) + _isAuthorized = await BoardAuthAsync(ct); + if (!_isAuthorized) { - Application.Quit(); - Debug.Log("Не пройдена авторизация на плате. Выход."); + Log("Повторная авторизация платы не пройдена."); + return false; } + + HandleAuthorizationSucceeded(); + Log($"Соединение с {_controllerPort} восстановлено и авторизовано."); + return true; } static bool IsInputIdleTooLong() { int last = Volatile.Read(ref _lastInputTick); - if (last == 0) return true; + if (last == 0) return false; int now = Environment.TickCount; // переполнение TickCount тут ок: сравнение интервала < 24 дней работает корректно int elapsed = unchecked(now - last); - return elapsed >= INPUT_IDLE_BEFORE_PING_MS; + return elapsed >= _inputIdleBeforePingMs; + } + + private static void MarkInputActivity() + => Volatile.Write(ref _lastInputTick, Environment.TickCount); + + private static void HandleAuthorizationSucceeded() + { + MarkInputActivity(); + Send(_cabinetLightingOnCommand); } private static void Log(string message) => logger?.Log($"CW{message}");