From 3e04727022d0a5c719c056198564295efde78194314f7c8077949b75e97de0d8 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 14 Jun 2026 18:21:23 +0400 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20ESP=20pairing=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B2=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dev_Docs/API/02_Authentication_API.md | 36 ++- Dev_Docs/API/03_Session_Management_API.md | 235 +++++++++++++++++ Dev_Docs/API/09_Operations_Index.md | 6 + .../2026-06-14_1715_server_esp_pairing.md | 23 ++ .../ESP_Pairing_и_режимы_подключения.md | 106 ++++++++ .../java/shine/db/DatabaseInitializer.java | 41 +++ .../java/shine/db/SqliteDbController.java | 65 ++++- .../shine/db/dao/EspPairingRequestsDAO.java | 249 ++++++++++++++++++ .../shine/db/dao/EspPairingSettingsDAO.java | 101 +++++++ .../db/entities/EspPairingRequestEntry.java | 149 +++++++++++ .../db/entities/EspPairingSettingsEntry.java | 77 ++++++ .../ws_protocol/JSON/JsonHandlerRegistry.java | 24 ++ .../JSON/handlers/auth/EspPairingSupport.java | 152 +++++++++++ .../auth/Net_ApproveEspPairing_Handler.java | 63 +++++ .../auth/Net_GetEspPairingStatus_Handler.java | 50 ++++ .../Net_ListEspPairingRequests_Handler.java | 58 ++++ .../auth/Net_RejectEspPairing_Handler.java | 58 ++++ .../auth/Net_StartEspPairing_Handler.java | 151 +++++++++++ .../Net_UpsertEspPairingSettings_Handler.java | 56 ++++ .../Net_ApproveEspPairing_Request.java | 24 ++ .../Net_ApproveEspPairing_Response.java | 24 ++ .../Net_GetEspPairingStatus_Request.java | 15 ++ .../Net_GetEspPairingStatus_Response.java | 78 ++++++ .../Net_ListEspPairingRequests_Request.java | 6 + .../Net_ListEspPairingRequests_Response.java | 120 +++++++++ .../Net_RejectEspPairing_Request.java | 24 ++ .../Net_RejectEspPairing_Response.java | 24 ++ .../entyties/Net_StartEspPairing_Request.java | 60 +++++ .../Net_StartEspPairing_Response.java | 60 +++++ .../Net_UpsertEspPairingSettings_Request.java | 33 +++ ...Net_UpsertEspPairingSettings_Response.java | 24 ++ .../java/test/it/cases/IT_07_EspPairing.java | 170 ++++++++++++ .../java/test/it/runner/IT_RunAllMain.java | 6 +- .../java/test/it/utils/json/JsonBuilders.java | 106 +++++++- .../java/test/it/utils/json/JsonParsers.java | 28 ++ VERSION.properties | 4 +- 36 files changed, 2500 insertions(+), 6 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-14_1715_server_esp_pairing.md create mode 100644 Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingRequestEntry.java create mode 100644 SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingSettingsEntry.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ApproveEspPairing_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_GetEspPairingStatus_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListEspPairingRequests_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_RejectEspPairing_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_UpsertEspPairingSettings_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Response.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Response.java create mode 100644 SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java diff --git a/Dev_Docs/API/02_Authentication_API.md b/Dev_Docs/API/02_Authentication_API.md index f1b1e45..d70c27b 100644 --- a/Dev_Docs/API/02_Authentication_API.md +++ b/Dev_Docs/API/02_Authentication_API.md @@ -2,7 +2,7 @@ Этот файл описывает именно этапы авторизации клиента, то есть как создать новую сессию и как войти в уже существующую. -Здесь четыре метода: +Здесь четыре базовых метода обычной авторизации: - `AuthChallenge` - `CreateAuthSession` @@ -36,6 +36,16 @@ Ниже в документе сначала описан сценарий, а потом зафиксированы точные форматы запросов и ответов. +Отдельно появился новый серверный сценарий pairing через доверенный homeserver/ESP. Он не заменяет обычный вход и описан в: + +- `Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md` + +Кратко: + +- `AuthChallenge/CreateAuthSession` и `SessionChallenge/SessionLogin` остаются каноническими потоками обычной авторизации; +- pairing через ESP идёт отдельными `op` и только подготавливает безопасное добавление новой сессии; +- решение об одобрении pairing принимает любая уже авторизованная доверенная сессия пользователя. + ## 1. Поток авторизации Поддерживаются два сценария: @@ -289,6 +299,30 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} --- +## 6. Pairing через homeserver/ESP + +Новые `op`, относящиеся к этому сценарию: + +- `UpsertEspPairingSettings` +- `StartEspPairing` +- `ListEspPairingRequests` +- `ApproveEspPairing` +- `RejectEspPairing` +- `GetEspPairingStatus` + +В этом потоке: + +- новое устройство не владеет `deviceKey` и не проходит обычный `CreateAuthSession`; +- пароль проверяется сервером только как фильтр; +- решение об одобрении принимает уже авторизованная доверенная сессия пользователя; +- сервер не расшифровывает `encryptedPayload` и не становится источником приватных ключей. + +Точные форматы этих операций см. в `03_Session_Management_API.md` и в протокольном документе: + +- `Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md` + +--- + ## 6. Пример ошибки ```json diff --git a/Dev_Docs/API/03_Session_Management_API.md b/Dev_Docs/API/03_Session_Management_API.md index 4a53969..13715a5 100644 --- a/Dev_Docs/API/03_Session_Management_API.md +++ b/Dev_Docs/API/03_Session_Management_API.md @@ -7,6 +7,18 @@ - `ListSessions` — получить список активных сессий пользователя; - `CloseActiveSession` — закрыть одну из активных сессий. +Дополнительно в этом же слое управления сессиями появился сценарий pairing через доверенную уже авторизованную сессию пользователя: + +- `UpsertEspPairingSettings` +- `ListEspPairingRequests` +- `ApproveEspPairing` +- `RejectEspPairing` + +Анонимное новое устройство работает с двумя связанными операциями: + +- `StartEspPairing` +- `GetEspPairingStatus` + Логика раздела такая: - сначала пользователь проходит `SessionLogin`; @@ -151,3 +163,226 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M Важно: это **не человеко-читаемое имя**, а непрозрачный идентификатор. Нужно передавать его как есть, без нормализации регистра и без URL-экранирования внутри JSON. + +--- + +## 5. ESP pairing через доверенную сессию + +Этот блок относится к сценарию добавления новой сессии через доверенное устройство пользователя. + +### 5.1. `UpsertEspPairingSettings` + +Доступно для любой уже авторизованной доверенной сессии пользователя. + +### Запрос + +```json +{ + "op": "UpsertEspPairingSettings", + "requestId": "esp-set-001", + "payload": { + "enabled": true, + "passwordHash": "argon2id$...", + "ttlSeconds": 180 + } +} +``` + +### Успешный ответ + +```json +{ + "op": "UpsertEspPairingSettings", + "requestId": "esp-set-001", + "status": 200, + "ok": true, + "payload": { + "enabled": true, + "ttlSeconds": 180 + } +} +``` + +### Ошибки + +- `400 / EMPTY_PASSWORD_HASH` — попытка включить pairing без `passwordHash`. +- `463 / PAIRING_REQUIRES_AUTH_SESSION` — операция вызвана без уже авторизованной доверенной сессии пользователя. + +### 5.2. `StartEspPairing` + +Эта операция доступна без уже существующей пользовательской сессии. + +### Запрос + +```json +{ + "op": "StartEspPairing", + "requestId": "esp-start-001", + "payload": { + "login": "alice", + "passwordHash": "argon2id$...", + "requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY", + "requesterSessionType": 1, + "requesterClientPlatform": "Android", + "payloadType": 1 + } +} +``` + +Поле `trustedSessionOnline` показывает, что у пользователя сейчас есть хотя бы одна онлайн доверенная сессия, способная принять pairing-заявку. + +### Успешный ответ + +```json +{ + "op": "StartEspPairing", + "requestId": "esp-start-001", + "status": 200, + "ok": true, + "payload": { + "pairingId": "base64url", + "state": "created", + "shortCode": "4920709", + "fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA", + "expiresAtMs": 1781441990538, + "trustedSessionOnline": true + } +} +``` + +### Ошибки + +- `400 / EMPTY_LOGIN` +- `400 / EMPTY_PASSWORD_HASH` +- `400 / EMPTY_REQUESTER_SESSION_KEY` +- `400 / BAD_REQUESTER_SESSION_KEY` +- `400 / BAD_SESSION_TYPE` +- `400 / BAD_PAYLOAD_TYPE` +- `422 / PAIRING_NOT_AVAILABLE` +- `422 / PAIRING_PASSWORD_INVALID` +- `429 / PAIRING_RATE_LIMITED` + +### 5.3. `ListEspPairingRequests` + +Доступно для любой уже авторизованной доверенной сессии пользователя. + +### Успешный ответ + +```json +{ + "op": "ListEspPairingRequests", + "requestId": "esp-list-001", + "status": 200, + "ok": true, + "payload": { + "requests": [ + { + "pairingId": "base64url", + "state": "created", + "requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY", + "requesterSessionType": 1, + "requesterClientPlatform": "Android", + "payloadType": 1, + "shortCode": "4920709", + "fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA", + "createdAtMs": 1781441810538, + "expiresAtMs": 1781441990538, + "deliveredToHomeserver": true + } + ] + } +} +``` + +### Ошибки + +- `463 / PAIRING_REQUIRES_AUTH_SESSION` + +### 5.4. `ApproveEspPairing` + +Доступно для любой уже авторизованной доверенной сессии пользователя. + +### Запрос + +```json +{ + "op": "ApproveEspPairing", + "requestId": "esp-approve-001", + "payload": { + "pairingId": "base64url", + "encryptedPayload": "BASE64_OR_OTHER_OPAQUE_PAYLOAD" + } +} +``` + +### Успешный ответ + +```json +{ + "op": "ApproveEspPairing", + "requestId": "esp-approve-001", + "status": 200, + "ok": true, + "payload": { + "pairingId": "base64url", + "state": "approved" + } +} +``` + +### Ошибки + +- `400 / EMPTY_PAIRING_ID` +- `400 / EMPTY_ENCRYPTED_PAYLOAD` +- `404 / PAIRING_NOT_FOUND` +- `422 / PAIRING_OF_ANOTHER_USER` +- `422 / PAIRING_NOT_PENDING` +- `422 / PAIRING_EXPIRED` +- `463 / PAIRING_REQUIRES_AUTH_SESSION` + +### 5.5. `RejectEspPairing` + +Доступно для любой уже авторизованной доверенной сессии пользователя. Похоже на approve, но переводит заявку в `state=rejected`. + +### 5.6. `GetEspPairingStatus` + +Операция для нового устройства. + +### Запрос + +```json +{ + "op": "GetEspPairingStatus", + "requestId": "esp-status-001", + "payload": { + "pairingId": "base64url" + } +} +``` + +### Успешный ответ после approve + +```json +{ + "op": "GetEspPairingStatus", + "requestId": "esp-status-001", + "status": 200, + "ok": true, + "payload": { + "pairingId": "base64url", + "state": "approved", + "shortCode": "4920709", + "fingerprintB58": "ASvYDPQidnAroKzQjtCjTuEQE8ckktV5nmmhYRhDzGaA", + "payloadType": 1, + "encryptedPayload": "AQIDBA==", + "expiresAtMs": 1781441990538 + } +} +``` + +### Возможные `state` + +- `created` +- `approved` +- `rejected` +- `expired` diff --git a/Dev_Docs/API/09_Operations_Index.md b/Dev_Docs/API/09_Operations_Index.md index 0033b48..83c7806 100644 --- a/Dev_Docs/API/09_Operations_Index.md +++ b/Dev_Docs/API/09_Operations_Index.md @@ -19,6 +19,12 @@ | `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии | | `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию | | `SessionLogin` | `02_Authentication_API.md` | вход в существующую сессию | +| `UpsertEspPairingSettings` | `03_Session_Management_API.md` | включение/обновление pairing-настроек доверенной сессией | +| `StartEspPairing` | `03_Session_Management_API.md` | создание pairing-заявки для нового устройства | +| `ListEspPairingRequests` | `03_Session_Management_API.md` | список активных pairing-заявок для доверенной сессии | +| `ApproveEspPairing` | `03_Session_Management_API.md` | подтверждение pairing-заявки доверенной сессией | +| `RejectEspPairing` | `03_Session_Management_API.md` | отклонение pairing-заявки доверенной сессией | +| `GetEspPairingStatus` | `03_Session_Management_API.md` | чтение статуса и результата pairing-заявки | | `ListSessions` | `03_Session_Management_API.md` | список активных сессий | | `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии | | `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн | diff --git a/Dev_Docs/Pending_Features/2026-06-14_1715_server_esp_pairing.md b/Dev_Docs/Pending_Features/2026-06-14_1715_server_esp_pairing.md new file mode 100644 index 0000000..4d16fe1 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-14_1715_server_esp_pairing.md @@ -0,0 +1,23 @@ +# server esp pairing + +- краткое описание фичи: + - на сервере добавлен отдельный pairing-сценарий для подключения нового устройства через доверенную уже авторизованную сессию пользователя без выдачи приватных ключей сервером; + - добавлены `op`: `UpsertEspPairingSettings`, `StartEspPairing`, `ListEspPairingRequests`, `ApproveEspPairing`, `RejectEspPairing`, `GetEspPairingStatus`; + - подтверждать pairing может любая доверенная сессия пользователя. + +- что именно проверять: + - любая уже авторизованная сессия пользователя включает pairing и задаёт `passwordHash`; + - новое устройство создаёт заявку через `StartEspPairing`; + - другая доверенная сессия пользователя видит заявку в `ListEspPairingRequests`; + - доверенная сессия подтверждает заявку через `ApproveEspPairing`; + - новое устройство получает `approved + encryptedPayload` через `GetEspPairingStatus`; + - неавторизованное новое устройство не может вызывать управляющие pairing-операции. + +- ожидаемый результат: + - заявка создаётся со статусом `created`; + - у заявки есть `pairingId`, `shortCode`, `fingerprintB58`, `expiresAtMs`; + - после approve статус становится `approved`, а `encryptedPayload` возвращается новому устройству; + - неавторизованное соединение получает отказ `463 / PAIRING_REQUIRES_AUTH_SESSION`. + +- статус: + - `pending` diff --git a/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md b/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md new file mode 100644 index 0000000..282bff1 --- /dev/null +++ b/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md @@ -0,0 +1,106 @@ +# ESP Pairing и режимы подключения + +Этот документ фиксирует текущие и новые режимы входа/подключения в SHiNE без клиентской UI-реализации. Он нужен как отдельная точка входа по сценариям подключения, чтобы не смешивать обычную авторизацию и серверный pairing через доверенное уже авторизованное устройство пользователя. + +## 1. Текущие режимы + +### 1. Создание новой сессии через `deviceKey` + +Поток: + +`AuthChallenge -> CreateAuthSession` + +Смысл: + +- новое устройство уже владеет приватным `deviceKey`; +- сервер проверяет подпись `deviceKey`; +- создаётся обычная активная сессия пользователя; +- этот поток остаётся без изменений. + +### 2. Повторный вход в существующую сессию через `sessionKey` + +Поток: + +`SessionChallenge -> SessionLogin` + +Смысл: + +- устройство уже владеет приватным `sessionKey`; +- сервер проверяет подпись `sessionKey`; +- соединение снова входит в существующую сессию; +- этот поток тоже остаётся без изменений. + +## 2. Новый режим: добавление сессии через доверенное устройство пользователя + +Новый поток не заменяет обычный логин, а живёт рядом с ним. + +Цель: + +- новое устройство знает `login + pairing password`; +- сервер использует пароль только как фильтр от мусора; +- реальное доверие даёт любая уже онлайн доверенная сессия пользователя; +- сервер не выдаёт приватные ключи сам от себя. + +Поток версии `v1`: + +1. Любая доверенная сессия пользователя создаёт на сервере pairing-настройку: + `UpsertEspPairingSettings` +2. Новое устройство создаёт pending-заявку: + `StartEspPairing` +3. Онлайн доверенная сессия видит список активных заявок: + `ListEspPairingRequests` +4. Доверенная сессия либо подтверждает заявку: + `ApproveEspPairing` +5. Либо отклоняет: + `RejectEspPairing` +6. Новое устройство читает результат: + `GetEspPairingStatus` + +## 3. Что именно делает сервер + +- хранит включённость pairing и opaque `passwordHash`; +- хранит pending/approved/rejected pairing-заявки; +- рассчитывает короткий код `shortCode` из `7` цифр; +- рассчитывает длинный `fingerprintB58` из `SHA-256` заявки; +- уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены; +- хранит переданный `encryptedPayload` как непрозрачную строку и не анализирует его содержимое. + +## 4. Чего сервер в этой версии не делает + +- не передаёт приватный `deviceKey`; +- не расшифровывает `encryptedPayload`; +- не проверяет криптографию содержимого payload; +- не делает клиентский UI; +- не навязывает конкретную схему `Ed25519 -> X25519` в коде сервера. + +Это намеренно: серверная версия `v1` подготавливает безопасный каркас маршрутизации и состояния, а настоящая E2E-логика упаковки ключей будет жить на клиентах и ESP-устройствах. + +## 5. Роли и ограничения + +- любая уже авторизованная доверенная сессия пользователя может вызывать: + - `UpsertEspPairingSettings` + - `ListEspPairingRequests` + - `ApproveEspPairing` + - `RejectEspPairing` +- новое устройство может вызвать `StartEspPairing` и `GetEspPairingStatus` без уже существующей авторизованной сессии; +- `payloadType` поддерживается в вариантах: + - `1` — минимальный пакет + - `2` — расширенный пакет + - `3` — полный пакет + +Сервер не интерпретирует эти три типа глубже, а только фиксирует их в состоянии заявки. + +## 6. Статусы pairing-заявки + +- `created` — заявка создана и ждёт решения доверенной сессии; +- `approved` — доверенная сессия подтвердила и приложила `encryptedPayload`; +- `rejected` — доверенная сессия отклонила заявку; +- `expired` — TTL заявки истёк до подтверждения. + +## 7. Практический смысл + +Эта схема даёт нужное разделение доверия: + +- пароль на сервере только отсеивает лишних; +- онлайн доверенная сессия решает, добавлять ли новую сессию; +- сервер остаётся маршрутизатором и хранилищем состояния, а не владельцем секретов. diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index b664bbb..a3f210c 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -190,6 +190,47 @@ public final class DatabaseInitializer { ON active_sessions (login); """); + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS esp_pairing_settings ( + login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, + enabled INTEGER NOT NULL DEFAULT 0, + password_hash TEXT NOT NULL DEFAULT '', + ttl_seconds INTEGER NOT NULL DEFAULT 300, + failed_attempts INTEGER NOT NULL DEFAULT 0, + first_failed_at_ms INTEGER NOT NULL DEFAULT 0, + blocked_until_ms INTEGER NOT NULL DEFAULT 0, + updated_at_ms INTEGER NOT NULL, + FOREIGN KEY (login) REFERENCES solana_users(login) + ); + """); + + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS esp_pairing_requests ( + pairing_id TEXT NOT NULL PRIMARY KEY, + login TEXT NOT NULL, + requester_session_key TEXT NOT NULL, + requester_session_type INTEGER NOT NULL DEFAULT 1, + requester_client_platform TEXT NOT NULL DEFAULT '', + payload_type INTEGER NOT NULL, + status TEXT NOT NULL, + short_code TEXT NOT NULL, + fingerprint_b58 TEXT NOT NULL, + encrypted_payload TEXT, + reject_reason TEXT, + approved_by_session_id TEXT, + created_at_ms INTEGER NOT NULL, + expires_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + delivered_to_homeserver INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (login) REFERENCES solana_users(login) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status + ON esp_pairing_requests (login, status, expires_at_ms); + """); + // 3. users_params st.executeUpdate(""" CREATE TABLE IF NOT EXISTS users_params ( diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java index afa321b..35bda33 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java @@ -14,7 +14,7 @@ import java.sql.Statement; public final class SqliteDbController { private static volatile SqliteDbController instance; - private static final int LATEST_SCHEMA_VERSION = 4; + private static final int LATEST_SCHEMA_VERSION = 5; private final String jdbcUrl; @@ -87,6 +87,7 @@ public final class SqliteDbController { case 2 -> migrateToV2(); case 3 -> migrateToV3(); case 4 -> migrateToV4(); + case 5 -> migrateToV5(); default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); } } @@ -189,6 +190,25 @@ public final class SqliteDbController { } } + private void migrateToV5() { + try (Connection c = DriverManager.getConnection(jdbcUrl); + Statement st = c.createStatement()) { + c.setAutoCommit(false); + try { + ensureEspPairingTables(st); + setSchemaVersion(c, 5); + c.commit(); + } catch (Exception e) { + try { c.rollback(); } catch (Exception ignored) {} + throw new RuntimeException("DB migration to v5 failed", e); + } finally { + try { c.setAutoCommit(true); } catch (Exception ignored) {} + } + } catch (SQLException e) { + throw new RuntimeException("DB migration to v5 failed", e); + } + } + private static void ensureChat200StateTables(Statement st) throws SQLException { st.executeUpdate(""" CREATE TABLE IF NOT EXISTS chat200_state ( @@ -266,6 +286,49 @@ public final class SqliteDbController { st.executeUpdate("ALTER TABLE active_sessions ADD COLUMN client_platform TEXT NOT NULL DEFAULT ''"); } + private static void ensureEspPairingTables(Statement st) throws SQLException { + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS esp_pairing_settings ( + login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, + enabled INTEGER NOT NULL DEFAULT 0, + password_hash TEXT NOT NULL DEFAULT '', + ttl_seconds INTEGER NOT NULL DEFAULT 300, + failed_attempts INTEGER NOT NULL DEFAULT 0, + first_failed_at_ms INTEGER NOT NULL DEFAULT 0, + blocked_until_ms INTEGER NOT NULL DEFAULT 0, + updated_at_ms INTEGER NOT NULL, + FOREIGN KEY (login) REFERENCES solana_users(login) + ); + """); + + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS esp_pairing_requests ( + pairing_id TEXT NOT NULL PRIMARY KEY, + login TEXT NOT NULL, + requester_session_key TEXT NOT NULL, + requester_session_type INTEGER NOT NULL DEFAULT 1, + requester_client_platform TEXT NOT NULL DEFAULT '', + payload_type INTEGER NOT NULL, + status TEXT NOT NULL, + short_code TEXT NOT NULL, + fingerprint_b58 TEXT NOT NULL, + encrypted_payload TEXT, + reject_reason TEXT, + approved_by_session_id TEXT, + created_at_ms INTEGER NOT NULL, + expires_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + delivered_to_homeserver INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (login) REFERENCES solana_users(login) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_esp_pairing_requests_login_status + ON esp_pairing_requests (login, status, expires_at_ms); + """); + } + private static boolean columnExists(Connection c, String tableName, String columnName) throws SQLException { try (Statement probe = c.createStatement(); ResultSet rs = probe.executeQuery("PRAGMA table_info(" + tableName + ")")) { diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java new file mode 100644 index 0000000..0aaede8 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java @@ -0,0 +1,249 @@ +package shine.db.dao; + +import shine.db.SqliteDbController; +import shine.db.entities.EspPairingRequestEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +public final class EspPairingRequestsDAO { + + private static volatile EspPairingRequestsDAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + + private EspPairingRequestsDAO() { } + + public static EspPairingRequestsDAO getInstance() { + if (instance == null) { + synchronized (EspPairingRequestsDAO.class) { + if (instance == null) instance = new EspPairingRequestsDAO(); + } + } + return instance; + } + + public void insert(EspPairingRequestEntry entry) throws SQLException { + try (Connection c = db.getConnection()) { + insert(c, entry); + } + } + + public void insert(Connection c, EspPairingRequestEntry entry) throws SQLException { + String sql = """ + INSERT INTO esp_pairing_requests ( + pairing_id, + login, + requester_session_key, + requester_session_type, + requester_client_platform, + payload_type, + status, + short_code, + fingerprint_b58, + encrypted_payload, + reject_reason, + approved_by_session_id, + created_at_ms, + expires_at_ms, + updated_at_ms, + delivered_to_homeserver + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, entry.getPairingId()); + ps.setString(2, entry.getLogin()); + ps.setString(3, entry.getRequesterSessionKey()); + ps.setInt(4, entry.getRequesterSessionType()); + ps.setString(5, entry.getRequesterClientPlatform()); + ps.setInt(6, entry.getPayloadType()); + ps.setString(7, entry.getStatus()); + ps.setString(8, entry.getShortCode()); + ps.setString(9, entry.getFingerprintB58()); + ps.setString(10, entry.getEncryptedPayload()); + ps.setString(11, entry.getRejectReason()); + ps.setString(12, entry.getApprovedBySessionId()); + ps.setLong(13, entry.getCreatedAtMs()); + ps.setLong(14, entry.getExpiresAtMs()); + ps.setLong(15, entry.getUpdatedAtMs()); + ps.setInt(16, entry.isDeliveredToHomeserver() ? 1 : 0); + ps.executeUpdate(); + } + } + + public EspPairingRequestEntry getByPairingId(String pairingId) throws SQLException { + try (Connection c = db.getConnection()) { + return getByPairingId(c, pairingId); + } + } + + public EspPairingRequestEntry getByPairingId(Connection c, String pairingId) throws SQLException { + String sql = """ + SELECT pairing_id, login, requester_session_key, requester_session_type, requester_client_platform, + payload_type, status, short_code, fingerprint_b58, encrypted_payload, reject_reason, + approved_by_session_id, created_at_ms, expires_at_ms, updated_at_ms, delivered_to_homeserver + FROM esp_pairing_requests + WHERE pairing_id = ? + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, pairingId); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + + public List listActiveByLogin(String login, long nowMs) throws SQLException { + try (Connection c = db.getConnection()) { + return listActiveByLogin(c, login, nowMs); + } + } + + public List listActiveByLogin(Connection c, String login, long nowMs) throws SQLException { + String sql = """ + SELECT pairing_id, login, requester_session_key, requester_session_type, requester_client_platform, + payload_type, status, short_code, fingerprint_b58, encrypted_payload, reject_reason, + approved_by_session_id, created_at_ms, expires_at_ms, updated_at_ms, delivered_to_homeserver + FROM esp_pairing_requests + WHERE login = ? COLLATE NOCASE + AND expires_at_ms > ? + AND status IN ('created', 'approved', 'rejected') + ORDER BY created_at_ms DESC + """; + List list = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + ps.setLong(2, nowMs); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) list.add(mapRow(rs)); + } + } + return list; + } + + public int countRecentByLoginAndStatuses(String login, long sinceMs, String... statuses) throws SQLException { + try (Connection c = db.getConnection()) { + StringBuilder sql = new StringBuilder(""" + SELECT COUNT(*) + FROM esp_pairing_requests + WHERE login = ? COLLATE NOCASE + AND created_at_ms >= ? + AND status IN ( + """); + for (int i = 0; i < statuses.length; i++) { + if (i > 0) sql.append(", "); + sql.append("?"); + } + sql.append(")"); + try (PreparedStatement ps = c.prepareStatement(sql.toString())) { + ps.setString(1, login); + ps.setLong(2, sinceMs); + for (int i = 0; i < statuses.length; i++) { + ps.setString(3 + i, statuses[i]); + } + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getInt(1) : 0; + } + } + } + } + + public void updateDeliveryFlag(String pairingId, boolean delivered, long updatedAtMs) throws SQLException { + updateSimple(pairingId, """ + UPDATE esp_pairing_requests + SET delivered_to_homeserver = ?, updated_at_ms = ? + WHERE pairing_id = ? + """, ps -> { + ps.setInt(1, delivered ? 1 : 0); + ps.setLong(2, updatedAtMs); + ps.setString(3, pairingId); + }); + } + + public void markApproved(String pairingId, String encryptedPayload, String approvedBySessionId, long updatedAtMs) throws SQLException { + updateSimple(pairingId, """ + UPDATE esp_pairing_requests + SET status = 'approved', + encrypted_payload = ?, + approved_by_session_id = ?, + reject_reason = NULL, + updated_at_ms = ? + WHERE pairing_id = ? + """, ps -> { + ps.setString(1, encryptedPayload); + ps.setString(2, approvedBySessionId); + ps.setLong(3, updatedAtMs); + ps.setString(4, pairingId); + }); + } + + public void markRejected(String pairingId, String rejectReason, String approvedBySessionId, long updatedAtMs) throws SQLException { + updateSimple(pairingId, """ + UPDATE esp_pairing_requests + SET status = 'rejected', + reject_reason = ?, + approved_by_session_id = ?, + encrypted_payload = NULL, + updated_at_ms = ? + WHERE pairing_id = ? + """, ps -> { + ps.setString(1, rejectReason); + ps.setString(2, approvedBySessionId); + ps.setLong(3, updatedAtMs); + ps.setString(4, pairingId); + }); + } + + public int expirePending(long nowMs) throws SQLException { + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(""" + UPDATE esp_pairing_requests + SET status = 'expired', + updated_at_ms = ? + WHERE status = 'created' + AND expires_at_ms <= ? + """)) { + ps.setLong(1, nowMs); + ps.setLong(2, nowMs); + return ps.executeUpdate(); + } + } + + private interface PreparedStatementSetter { + void accept(PreparedStatement ps) throws SQLException; + } + + private void updateSimple(String pairingId, String sql, PreparedStatementSetter setter) throws SQLException { + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement(sql)) { + setter.accept(ps); + ps.executeUpdate(); + } + } + + private static EspPairingRequestEntry mapRow(ResultSet rs) throws SQLException { + EspPairingRequestEntry entry = new EspPairingRequestEntry(); + entry.setPairingId(rs.getString("pairing_id")); + entry.setLogin(rs.getString("login")); + entry.setRequesterSessionKey(rs.getString("requester_session_key")); + entry.setRequesterSessionType(rs.getInt("requester_session_type")); + entry.setRequesterClientPlatform(rs.getString("requester_client_platform")); + entry.setPayloadType(rs.getInt("payload_type")); + entry.setStatus(rs.getString("status")); + entry.setShortCode(rs.getString("short_code")); + entry.setFingerprintB58(rs.getString("fingerprint_b58")); + entry.setEncryptedPayload(rs.getString("encrypted_payload")); + entry.setRejectReason(rs.getString("reject_reason")); + entry.setApprovedBySessionId(rs.getString("approved_by_session_id")); + entry.setCreatedAtMs(rs.getLong("created_at_ms")); + entry.setExpiresAtMs(rs.getLong("expires_at_ms")); + entry.setUpdatedAtMs(rs.getLong("updated_at_ms")); + entry.setDeliveredToHomeserver(rs.getInt("delivered_to_homeserver") != 0); + return entry; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java new file mode 100644 index 0000000..873ba7d --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingSettingsDAO.java @@ -0,0 +1,101 @@ +package shine.db.dao; + +import shine.db.SqliteDbController; +import shine.db.entities.EspPairingSettingsEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +public final class EspPairingSettingsDAO { + + private static volatile EspPairingSettingsDAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + + private EspPairingSettingsDAO() { } + + public static EspPairingSettingsDAO getInstance() { + if (instance == null) { + synchronized (EspPairingSettingsDAO.class) { + if (instance == null) instance = new EspPairingSettingsDAO(); + } + } + return instance; + } + + public void upsert(EspPairingSettingsEntry entry) throws SQLException { + try (Connection c = db.getConnection()) { + upsert(c, entry); + } + } + + public void upsert(Connection c, EspPairingSettingsEntry entry) throws SQLException { + String sql = """ + INSERT INTO esp_pairing_settings ( + login, + enabled, + password_hash, + ttl_seconds, + failed_attempts, + first_failed_at_ms, + blocked_until_ms, + updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(login) DO UPDATE SET + enabled = excluded.enabled, + password_hash = excluded.password_hash, + ttl_seconds = excluded.ttl_seconds, + failed_attempts = excluded.failed_attempts, + first_failed_at_ms = excluded.first_failed_at_ms, + blocked_until_ms = excluded.blocked_until_ms, + updated_at_ms = excluded.updated_at_ms + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, entry.getLogin()); + ps.setInt(2, entry.isEnabled() ? 1 : 0); + ps.setString(3, entry.getPasswordHash()); + ps.setInt(4, entry.getTtlSeconds()); + ps.setInt(5, entry.getFailedAttempts()); + ps.setLong(6, entry.getFirstFailedAtMs()); + ps.setLong(7, entry.getBlockedUntilMs()); + ps.setLong(8, entry.getUpdatedAtMs()); + ps.executeUpdate(); + } + } + + public EspPairingSettingsEntry getByLogin(String login) throws SQLException { + try (Connection c = db.getConnection()) { + return getByLogin(c, login); + } + } + + public EspPairingSettingsEntry getByLogin(Connection c, String login) throws SQLException { + String sql = """ + SELECT login, enabled, password_hash, ttl_seconds, failed_attempts, first_failed_at_ms, blocked_until_ms, updated_at_ms + FROM esp_pairing_settings + WHERE login = ? COLLATE NOCASE + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + + private static EspPairingSettingsEntry mapRow(ResultSet rs) throws SQLException { + EspPairingSettingsEntry entry = new EspPairingSettingsEntry(); + entry.setLogin(rs.getString("login")); + entry.setEnabled(rs.getInt("enabled") != 0); + entry.setPasswordHash(rs.getString("password_hash")); + entry.setTtlSeconds(rs.getInt("ttl_seconds")); + entry.setFailedAttempts(rs.getInt("failed_attempts")); + entry.setFirstFailedAtMs(rs.getLong("first_failed_at_ms")); + entry.setBlockedUntilMs(rs.getLong("blocked_until_ms")); + entry.setUpdatedAtMs(rs.getLong("updated_at_ms")); + return entry; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingRequestEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingRequestEntry.java new file mode 100644 index 0000000..f78e7d2 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingRequestEntry.java @@ -0,0 +1,149 @@ +package shine.db.entities; + +public class EspPairingRequestEntry { + + private String pairingId; + private String login; + private String requesterSessionKey; + private int requesterSessionType; + private String requesterClientPlatform; + private int payloadType; + private String status; + private String shortCode; + private String fingerprintB58; + private String encryptedPayload; + private String rejectReason; + private String approvedBySessionId; + private long createdAtMs; + private long expiresAtMs; + private long updatedAtMs; + private boolean deliveredToHomeserver; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public String getRequesterSessionKey() { + return requesterSessionKey; + } + + public void setRequesterSessionKey(String requesterSessionKey) { + this.requesterSessionKey = requesterSessionKey; + } + + public int getRequesterSessionType() { + return requesterSessionType; + } + + public void setRequesterSessionType(int requesterSessionType) { + this.requesterSessionType = requesterSessionType; + } + + public String getRequesterClientPlatform() { + return requesterClientPlatform; + } + + public void setRequesterClientPlatform(String requesterClientPlatform) { + this.requesterClientPlatform = requesterClientPlatform; + } + + public int getPayloadType() { + return payloadType; + } + + public void setPayloadType(int payloadType) { + this.payloadType = payloadType; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getShortCode() { + return shortCode; + } + + public void setShortCode(String shortCode) { + this.shortCode = shortCode; + } + + public String getFingerprintB58() { + return fingerprintB58; + } + + public void setFingerprintB58(String fingerprintB58) { + this.fingerprintB58 = fingerprintB58; + } + + public String getEncryptedPayload() { + return encryptedPayload; + } + + public void setEncryptedPayload(String encryptedPayload) { + this.encryptedPayload = encryptedPayload; + } + + public String getRejectReason() { + return rejectReason; + } + + public void setRejectReason(String rejectReason) { + this.rejectReason = rejectReason; + } + + public String getApprovedBySessionId() { + return approvedBySessionId; + } + + public void setApprovedBySessionId(String approvedBySessionId) { + this.approvedBySessionId = approvedBySessionId; + } + + public long getCreatedAtMs() { + return createdAtMs; + } + + public void setCreatedAtMs(long createdAtMs) { + this.createdAtMs = createdAtMs; + } + + public long getExpiresAtMs() { + return expiresAtMs; + } + + public void setExpiresAtMs(long expiresAtMs) { + this.expiresAtMs = expiresAtMs; + } + + public long getUpdatedAtMs() { + return updatedAtMs; + } + + public void setUpdatedAtMs(long updatedAtMs) { + this.updatedAtMs = updatedAtMs; + } + + public boolean isDeliveredToHomeserver() { + return deliveredToHomeserver; + } + + public void setDeliveredToHomeserver(boolean deliveredToHomeserver) { + this.deliveredToHomeserver = deliveredToHomeserver; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingSettingsEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingSettingsEntry.java new file mode 100644 index 0000000..50aaee3 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/EspPairingSettingsEntry.java @@ -0,0 +1,77 @@ +package shine.db.entities; + +public class EspPairingSettingsEntry { + + private String login; + private boolean enabled; + private String passwordHash; + private int ttlSeconds; + private int failedAttempts; + private long firstFailedAtMs; + private long blockedUntilMs; + private long updatedAtMs; + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public int getTtlSeconds() { + return ttlSeconds; + } + + public void setTtlSeconds(int ttlSeconds) { + this.ttlSeconds = ttlSeconds; + } + + public int getFailedAttempts() { + return failedAttempts; + } + + public void setFailedAttempts(int failedAttempts) { + this.failedAttempts = failedAttempts; + } + + public long getFirstFailedAtMs() { + return firstFailedAtMs; + } + + public void setFirstFailedAtMs(long firstFailedAtMs) { + this.firstFailedAtMs = firstFailedAtMs; + } + + public long getBlockedUntilMs() { + return blockedUntilMs; + } + + public void setBlockedUntilMs(long blockedUntilMs) { + this.blockedUntilMs = blockedUntilMs; + } + + public long getUpdatedAtMs() { + return updatedAtMs; + } + + public void setUpdatedAtMs(long updatedAtMs) { + this.updatedAtMs = updatedAtMs; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 1c2176d..295bcb3 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -7,20 +7,32 @@ import server.logic.ws_protocol.JSON.handlers.auth.Net_AuthChallenge_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_CloseActiveSession_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_ListEspPairingRequests_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_ApproveEspPairing_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_GetEspPairingStatus_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_RejectEspPairing_Handler; // --- NEW v2 session login --- import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionChallenge_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_SessionLogin_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_StartEspPairing_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_UpsertEspPairingSettings_Handler; // --- auth entities --- import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRequests_Request; // --- NEW v2 entities --- +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ApproveEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RejectEspPairing_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_StartEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairingSettings_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; @@ -121,6 +133,12 @@ public final class JsonHandlerRegistry { // --- login to existing session in 2 steps --- Map.entry("SessionChallenge", new Net_SessionChallenge_Handler()), Map.entry("SessionLogin", new Net_SessionLogin_Handler()), + Map.entry("UpsertEspPairingSettings", new Net_UpsertEspPairingSettings_Handler()), + Map.entry("StartEspPairing", new Net_StartEspPairing_Handler()), + Map.entry("ListEspPairingRequests", new Net_ListEspPairingRequests_Handler()), + Map.entry("ApproveEspPairing", new Net_ApproveEspPairing_Handler()), + Map.entry("RejectEspPairing", new Net_RejectEspPairing_Handler()), + Map.entry("GetEspPairingStatus", new Net_GetEspPairingStatus_Handler()), // --- blockchain --- Map.entry("AddBlock", new Net_AddBlock_Handler()), @@ -179,6 +197,12 @@ public final class JsonHandlerRegistry { // --- NEW v2 --- Map.entry("SessionChallenge", Net_SessionChallenge_Request.class), Map.entry("SessionLogin", Net_SessionLogin_Request.class), + Map.entry("UpsertEspPairingSettings", Net_UpsertEspPairingSettings_Request.class), + Map.entry("StartEspPairing", Net_StartEspPairing_Request.class), + Map.entry("ListEspPairingRequests", Net_ListEspPairingRequests_Request.class), + Map.entry("ApproveEspPairing", Net_ApproveEspPairing_Request.class), + Map.entry("RejectEspPairing", Net_RejectEspPairing_Request.class), + Map.entry("GetEspPairingStatus", Net_GetEspPairingStatus_Request.class), // --- blockchain --- Map.entry("AddBlock", Net_AddBlock_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java new file mode 100644 index 0000000..170f540 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java @@ -0,0 +1,152 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import org.eclipse.jetty.websocket.api.Session; +import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; +import server.logic.ws_protocol.JSON.ConnectionContext; +import shine.db.entities.ActiveSessionEntry; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Locale; + +final class EspPairingSupport { + + static final int DEFAULT_TTL_SECONDS = 300; + static final int MIN_TTL_SECONDS = 60; + static final int MAX_TTL_SECONDS = 1800; + static final int REQUEST_RATE_LIMIT = 5; + static final long REQUEST_RATE_WINDOW_MS = 5 * 60_000L; + + static final int STATUS_PAIRING_REQUIRES_AUTH_SESSION = 463; + static final int STATUS_PAIRING_RATE_LIMIT = 429; + + static final String STATE_CREATED = "created"; + static final String STATE_APPROVED = "approved"; + static final String STATE_REJECTED = "rejected"; + static final String STATE_EXPIRED = "expired"; + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final char[] BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toCharArray(); + + private EspPairingSupport() {} + + static boolean isTrustedUserSession(ConnectionContext ctx) { + if (ctx == null || !ctx.isAuthenticatedUser()) return false; + ActiveSessionEntry activeSession = ctx.getActiveSession(); + return activeSession != null && activeSession.getSessionId() != null && !activeSession.getSessionId().isBlank(); + } + + static List findOnlineTrustedConnections(String login) { + List out = new ArrayList<>(); + for (ConnectionContext candidate : ActiveConnectionsRegistry.getInstance().getByLogin(login)) { + if (!isTrustedUserSession(candidate)) continue; + Session ws = candidate.getWsSession(); + if (ws == null || !ws.isOpen()) continue; + out.add(candidate); + } + return out; + } + + static int normalizeTtlSeconds(Integer raw) { + if (raw == null) return DEFAULT_TTL_SECONDS; + int value = raw; + if (value < MIN_TTL_SECONDS) return MIN_TTL_SECONDS; + if (value > MAX_TTL_SECONDS) return MAX_TTL_SECONDS; + return value; + } + + static int normalizePayloadType(Integer raw) { + if (raw == null) return 1; + return raw; + } + + static boolean isSupportedPayloadType(int payloadType) { + return payloadType >= 1 && payloadType <= 3; + } + + static String normalizeOpaqueHash(String raw) { + if (raw == null) return null; + String value = raw.trim(); + if (value.isEmpty()) return null; + if (value.length() > 512) return value.substring(0, 512); + return value; + } + + static String normalizeEncryptedPayload(String raw) { + if (raw == null) return null; + String value = raw.trim(); + if (value.isEmpty()) return null; + if (value.length() > 32768) return value.substring(0, 32768); + return value; + } + + static String normalizeReason(String raw) { + if (raw == null) return ""; + String value = raw.trim(); + if (value.length() <= 160) return value; + return value.substring(0, 160); + } + + static PairingFingerprint deriveFingerprint(String login, + String requesterSessionKey, + int requesterSessionType, + String clientPlatform, + int payloadType, + long createdAtMs) throws Exception { + String canonical = (login == null ? "" : login.toLowerCase(Locale.ROOT).trim()) + + "|" + requesterSessionKey + + "|" + requesterSessionType + + "|" + (clientPlatform == null ? "" : clientPlatform.trim()) + + "|" + payloadType + + "|" + createdAtMs; + byte[] digest = MessageDigest.getInstance("SHA-256").digest(canonical.getBytes(StandardCharsets.UTF_8)); + long code = ((digest[0] & 0xFFL) << 24) + | ((digest[1] & 0xFFL) << 16) + | ((digest[2] & 0xFFL) << 8) + | (digest[3] & 0xFFL); + long shortCodeNum = code % 10_000_000L; + String shortCode = String.format(Locale.ROOT, "%07d", shortCodeNum); + return new PairingFingerprint(shortCode, toBase58(digest)); + } + + static String newPairingId() { + byte[] random = new byte[32]; + RANDOM.nextBytes(random); + return Base64.getUrlEncoder().withoutPadding().encodeToString(random); + } + + static String toBase58(byte[] input) { + if (input == null || input.length == 0) return ""; + int zeros = 0; + while (zeros < input.length && input[zeros] == 0) zeros++; + byte[] copy = input.clone(); + byte[] tmp = new byte[copy.length * 2]; + int j = tmp.length; + int startAt = zeros; + while (startAt < copy.length) { + int mod = divmod58(copy, startAt); + if (copy[startAt] == 0) startAt++; + tmp[--j] = (byte) BASE58[mod]; + } + while (j < tmp.length && tmp[j] == BASE58[0]) j++; + while (--zeros >= 0) tmp[--j] = (byte) BASE58[0]; + return new String(tmp, j, tmp.length - j, StandardCharsets.US_ASCII); + } + + private static int divmod58(byte[] number, int startAt) { + int remainder = 0; + for (int i = startAt; i < number.length; i++) { + int digit256 = number[i] & 0xFF; + int temp = remainder * 256 + digit256; + number[i] = (byte) (temp / 58); + remainder = temp % 58; + } + return remainder; + } + + record PairingFingerprint(String shortCode, String fingerprintB58) {} +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ApproveEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ApproveEspPairing_Handler.java new file mode 100644 index 0000000..90c0719 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ApproveEspPairing_Handler.java @@ -0,0 +1,63 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ApproveEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ApproveEspPairing_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.entities.EspPairingRequestEntry; + +public class Net_ApproveEspPairing_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_ApproveEspPairing_Request req = (Net_ApproveEspPairing_Request) baseReq; + if (!EspPairingSupport.isTrustedUserSession(ctx)) { + return NetExceptionResponseFactory.error( + req, + EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION, + "PAIRING_REQUIRES_AUTH_SESSION", + "Операция доступна только для авторизованной доверенной сессии пользователя" + ); + } + + String pairingId = req.getPairingId() == null ? "" : req.getPairingId().trim(); + if (pairingId.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PAIRING_ID", "Пустой pairingId"); + } + String encryptedPayload = EspPairingSupport.normalizeEncryptedPayload(req.getEncryptedPayload()); + if (encryptedPayload == null) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_ENCRYPTED_PAYLOAD", "Пустой encryptedPayload"); + } + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + EspPairingRequestEntry row = EspPairingRequestsDAO.getInstance().getByPairingId(pairingId); + if (row == null) { + return NetExceptionResponseFactory.error(req, 404, "PAIRING_NOT_FOUND", "Pairing-заявка не найдена"); + } + if (!ctx.getLogin().equalsIgnoreCase(row.getLogin())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_OF_ANOTHER_USER", "Нельзя подтверждать pairing другого пользователя"); + } + if (!EspPairingSupport.STATE_CREATED.equals(row.getStatus())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_PENDING", "Заявка уже не находится в статусе created"); + } + if (row.getExpiresAtMs() <= now) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_EXPIRED", "Срок действия pairing-заявки истёк"); + } + + EspPairingRequestsDAO.getInstance().markApproved(pairingId, encryptedPayload, ctx.getSessionId(), now); + + Net_ApproveEspPairing_Response resp = new Net_ApproveEspPairing_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setPairingId(pairingId); + resp.setState(EspPairingSupport.STATE_APPROVED); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_GetEspPairingStatus_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_GetEspPairingStatus_Handler.java new file mode 100644 index 0000000..957ed26 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_GetEspPairingStatus_Handler.java @@ -0,0 +1,50 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.entities.EspPairingRequestEntry; + +public class Net_GetEspPairingStatus_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_GetEspPairingStatus_Request req = (Net_GetEspPairingStatus_Request) baseReq; + String pairingId = req.getPairingId() == null ? "" : req.getPairingId().trim(); + if (pairingId.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PAIRING_ID", "Пустой pairingId"); + } + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + EspPairingRequestEntry row = EspPairingRequestsDAO.getInstance().getByPairingId(pairingId); + if (row == null) { + return NetExceptionResponseFactory.error(req, 404, "PAIRING_NOT_FOUND", "Pairing-заявка не найдена"); + } + + String state = row.getStatus(); + if (row.getExpiresAtMs() <= now && EspPairingSupport.STATE_CREATED.equals(state)) { + state = EspPairingSupport.STATE_EXPIRED; + } + + Net_GetEspPairingStatus_Response resp = new Net_GetEspPairingStatus_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setPairingId(row.getPairingId()); + resp.setState(state); + resp.setShortCode(row.getShortCode()); + resp.setFingerprintB58(row.getFingerprintB58()); + resp.setPayloadType(row.getPayloadType()); + resp.setEncryptedPayload(row.getEncryptedPayload()); + resp.setRejectReason(row.getRejectReason()); + resp.setExpiresAtMs(row.getExpiresAtMs()); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListEspPairingRequests_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListEspPairingRequests_Handler.java new file mode 100644 index 0000000..5423b6e --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_ListEspPairingRequests_Handler.java @@ -0,0 +1,58 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRequests_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRequests_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.entities.EspPairingRequestEntry; + +import java.util.ArrayList; +import java.util.List; + +public class Net_ListEspPairingRequests_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_ListEspPairingRequests_Request req = (Net_ListEspPairingRequests_Request) baseReq; + if (!EspPairingSupport.isTrustedUserSession(ctx)) { + return NetExceptionResponseFactory.error( + req, + EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION, + "PAIRING_REQUIRES_AUTH_SESSION", + "Операция доступна только для авторизованной доверенной сессии пользователя" + ); + } + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + List rows = EspPairingRequestsDAO.getInstance().listActiveByLogin(ctx.getLogin(), now); + List items = new ArrayList<>(); + for (EspPairingRequestEntry row : rows) { + Net_ListEspPairingRequests_Response.PairingRequestItem item = new Net_ListEspPairingRequests_Response.PairingRequestItem(); + item.setPairingId(row.getPairingId()); + item.setState(row.getStatus()); + item.setRequesterSessionKey(row.getRequesterSessionKey()); + item.setRequesterSessionType(row.getRequesterSessionType()); + item.setRequesterClientPlatform(row.getRequesterClientPlatform()); + item.setPayloadType(row.getPayloadType()); + item.setShortCode(row.getShortCode()); + item.setFingerprintB58(row.getFingerprintB58()); + item.setCreatedAtMs(row.getCreatedAtMs()); + item.setExpiresAtMs(row.getExpiresAtMs()); + item.setDeliveredToHomeserver(row.isDeliveredToHomeserver()); + items.add(item); + } + + Net_ListEspPairingRequests_Response resp = new Net_ListEspPairingRequests_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setRequests(items); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_RejectEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_RejectEspPairing_Handler.java new file mode 100644 index 0000000..d73d143 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_RejectEspPairing_Handler.java @@ -0,0 +1,58 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RejectEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RejectEspPairing_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.entities.EspPairingRequestEntry; + +public class Net_RejectEspPairing_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_RejectEspPairing_Request req = (Net_RejectEspPairing_Request) baseReq; + if (!EspPairingSupport.isTrustedUserSession(ctx)) { + return NetExceptionResponseFactory.error( + req, + EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION, + "PAIRING_REQUIRES_AUTH_SESSION", + "Операция доступна только для авторизованной доверенной сессии пользователя" + ); + } + + String pairingId = req.getPairingId() == null ? "" : req.getPairingId().trim(); + if (pairingId.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PAIRING_ID", "Пустой pairingId"); + } + String reason = EspPairingSupport.normalizeReason(req.getReason()); + if (reason.isBlank()) reason = "rejected_by_homeserver"; + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + EspPairingRequestEntry row = EspPairingRequestsDAO.getInstance().getByPairingId(pairingId); + if (row == null) { + return NetExceptionResponseFactory.error(req, 404, "PAIRING_NOT_FOUND", "Pairing-заявка не найдена"); + } + if (!ctx.getLogin().equalsIgnoreCase(row.getLogin())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_OF_ANOTHER_USER", "Нельзя отклонять pairing другого пользователя"); + } + if (!EspPairingSupport.STATE_CREATED.equals(row.getStatus())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_PENDING", "Заявка уже не находится в статусе created"); + } + + EspPairingRequestsDAO.getInstance().markRejected(pairingId, reason, ctx.getSessionId(), now); + + Net_RejectEspPairing_Response resp = new Net_RejectEspPairing_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setPairingId(pairingId); + resp.setState(EspPairingSupport.STATE_REJECTED); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java new file mode 100644 index 0000000..9f5a399 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java @@ -0,0 +1,151 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_StartEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_StartEspPairing_Response; +import server.logic.ws_protocol.JSON.push.WsEventSender; +import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.JSON.utils.NetIdGenerator; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.dao.EspPairingSettingsDAO; +import shine.db.dao.SolanaUsersDAO; +import shine.db.entities.EspPairingRequestEntry; +import shine.db.entities.EspPairingSettingsEntry; +import shine.db.entities.SolanaUserEntry; + +import java.util.List; + +public class Net_StartEspPairing_Handler implements JsonMessageHandler { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_StartEspPairing_Request req = (Net_StartEspPairing_Request) baseReq; + + String login = req.getLogin() == null ? "" : req.getLogin().trim(); + if (login.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_LOGIN", "Пустой login"); + } + + String requesterSessionKey = req.getRequesterSessionKey(); + if (requesterSessionKey == null || requesterSessionKey.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_REQUESTER_SESSION_KEY", "Пустой requesterSessionKey"); + } + try { + requesterSessionKey = AuthKeyUtils.normalize(requesterSessionKey, "requesterSessionKey"); + AuthKeyUtils.parseEd25519PublicKey(requesterSessionKey, "requesterSessionKey"); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_REQUESTER_SESSION_KEY", "Некорректный requesterSessionKey"); + } + + int requesterSessionType = AuthSessionTypeSupport.normalizeRequestedSessionType(req.getRequesterSessionType()); + if (!AuthSessionTypeSupport.isSupportedSessionType(requesterSessionType)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_SESSION_TYPE", "Неподдерживаемый requesterSessionType"); + } + int payloadType = EspPairingSupport.normalizePayloadType(req.getPayloadType()); + if (!EspPairingSupport.isSupportedPayloadType(payloadType)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_PAYLOAD_TYPE", "payloadType должен быть 1, 2 или 3"); + } + String passwordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash()); + if (passwordHash == null) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PASSWORD_HASH", "Пустой passwordHash"); + } + + SolanaUserEntry user = SolanaUsersDAO.getInstance().getByLogin(login); + if (user == null) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен"); + } + String canonicalLogin = user.getLogin(); + + EspPairingSettingsEntry settings = EspPairingSettingsDAO.getInstance().getByLogin(canonicalLogin); + if (settings == null || !settings.isEnabled() || settings.getPasswordHash() == null || settings.getPasswordHash().isBlank()) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен"); + } + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + if (settings.getBlockedUntilMs() > now) { + return NetExceptionResponseFactory.error(req, EspPairingSupport.STATUS_PAIRING_RATE_LIMIT, "PAIRING_RATE_LIMITED", "Временная блокировка pairing по числу неудачных попыток"); + } + int recentAttempts = EspPairingRequestsDAO.getInstance().countRecentByLoginAndStatuses( + canonicalLogin, + now - EspPairingSupport.REQUEST_RATE_WINDOW_MS, + EspPairingSupport.STATE_CREATED, + EspPairingSupport.STATE_REJECTED + ); + if (recentAttempts >= EspPairingSupport.REQUEST_RATE_LIMIT) { + return NetExceptionResponseFactory.error(req, EspPairingSupport.STATUS_PAIRING_RATE_LIMIT, "PAIRING_RATE_LIMITED", "Слишком много pairing-запросов за короткое время"); + } + if (!settings.getPasswordHash().equals(passwordHash)) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_PASSWORD_INVALID", "Неверный pairing-пароль"); + } + + String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform()); + int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(settings.getTtlSeconds()); + EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint( + canonicalLogin, + requesterSessionKey, + requesterSessionType, + clientPlatform, + payloadType, + now + ); + + EspPairingRequestEntry entry = new EspPairingRequestEntry(); + entry.setPairingId(EspPairingSupport.newPairingId()); + entry.setLogin(canonicalLogin); + entry.setRequesterSessionKey(requesterSessionKey); + entry.setRequesterSessionType(requesterSessionType); + entry.setRequesterClientPlatform(clientPlatform); + entry.setPayloadType(payloadType); + entry.setStatus(EspPairingSupport.STATE_CREATED); + entry.setShortCode(fingerprint.shortCode()); + entry.setFingerprintB58(fingerprint.fingerprintB58()); + entry.setCreatedAtMs(now); + entry.setExpiresAtMs(now + ttlSeconds * 1000L); + entry.setUpdatedAtMs(now); + entry.setDeliveredToHomeserver(false); + EspPairingRequestsDAO.getInstance().insert(entry); + + List approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin); + boolean delivered = false; + for (ConnectionContext targetCtx : approverConnections) { + String eventId = NetIdGenerator.eventId("pair"); + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("pairingId", entry.getPairingId()); + payload.put("login", canonicalLogin); + payload.put("requesterSessionKey", requesterSessionKey); + payload.put("requesterSessionType", requesterSessionType); + payload.put("requesterClientPlatform", clientPlatform); + payload.put("payloadType", payloadType); + payload.put("shortCode", entry.getShortCode()); + payload.put("fingerprintB58", entry.getFingerprintB58()); + payload.put("createdAtMs", entry.getCreatedAtMs()); + payload.put("expiresAtMs", entry.getExpiresAtMs()); + delivered |= WsEventSender.sendEvent(targetCtx, "IncomingEspPairingRequest", eventId, payload); + } + if (delivered) { + EspPairingRequestsDAO.getInstance().updateDeliveryFlag(entry.getPairingId(), true, System.currentTimeMillis()); + } + + Net_StartEspPairing_Response resp = new Net_StartEspPairing_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setPairingId(entry.getPairingId()); + resp.setState(entry.getStatus()); + resp.setShortCode(entry.getShortCode()); + resp.setFingerprintB58(entry.getFingerprintB58()); + resp.setExpiresAtMs(entry.getExpiresAtMs()); + resp.setTrustedSessionOnline(!approverConnections.isEmpty()); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_UpsertEspPairingSettings_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_UpsertEspPairingSettings_Handler.java new file mode 100644 index 0000000..03b9763 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_UpsertEspPairingSettings_Handler.java @@ -0,0 +1,56 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairingSettings_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairingSettings_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingSettingsDAO; +import shine.db.entities.EspPairingSettingsEntry; + +public class Net_UpsertEspPairingSettings_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_UpsertEspPairingSettings_Request req = (Net_UpsertEspPairingSettings_Request) baseReq; + + if (!EspPairingSupport.isTrustedUserSession(ctx)) { + return NetExceptionResponseFactory.error( + req, + EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION, + "PAIRING_REQUIRES_AUTH_SESSION", + "Операция доступна только для авторизованной доверенной сессии пользователя" + ); + } + + boolean enabled = req.getEnabled() != null && req.getEnabled(); + String passwordHash = EspPairingSupport.normalizeOpaqueHash(req.getPasswordHash()); + int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(req.getTtlSeconds()); + if (enabled && (passwordHash == null || passwordHash.isBlank())) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PASSWORD_HASH", "Для включения pairing нужен passwordHash"); + } + + long now = System.currentTimeMillis(); + EspPairingSettingsEntry entry = new EspPairingSettingsEntry(); + entry.setLogin(ctx.getLogin()); + entry.setEnabled(enabled); + entry.setPasswordHash(passwordHash == null ? "" : passwordHash); + entry.setTtlSeconds(ttlSeconds); + entry.setFailedAttempts(0); + entry.setFirstFailedAtMs(0L); + entry.setBlockedUntilMs(0L); + entry.setUpdatedAtMs(now); + EspPairingSettingsDAO.getInstance().upsert(entry); + + Net_UpsertEspPairingSettings_Response resp = new Net_UpsertEspPairingSettings_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setEnabled(enabled); + resp.setTtlSeconds(ttlSeconds); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Request.java new file mode 100644 index 0000000..61d49d4 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Request.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_ApproveEspPairing_Request extends Net_Request { + private String pairingId; + private String encryptedPayload; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getEncryptedPayload() { + return encryptedPayload; + } + + public void setEncryptedPayload(String encryptedPayload) { + this.encryptedPayload = encryptedPayload; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Response.java new file mode 100644 index 0000000..9052198 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ApproveEspPairing_Response.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_ApproveEspPairing_Response extends Net_Response { + private String pairingId; + private String state; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Request.java new file mode 100644 index 0000000..3a071da --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Request.java @@ -0,0 +1,15 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_GetEspPairingStatus_Request extends Net_Request { + private String pairingId; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Response.java new file mode 100644 index 0000000..8683d02 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_GetEspPairingStatus_Response.java @@ -0,0 +1,78 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_GetEspPairingStatus_Response extends Net_Response { + private String pairingId; + private String state; + private String shortCode; + private String fingerprintB58; + private int payloadType; + private String encryptedPayload; + private String rejectReason; + private long expiresAtMs; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getShortCode() { + return shortCode; + } + + public void setShortCode(String shortCode) { + this.shortCode = shortCode; + } + + public String getFingerprintB58() { + return fingerprintB58; + } + + public void setFingerprintB58(String fingerprintB58) { + this.fingerprintB58 = fingerprintB58; + } + + public int getPayloadType() { + return payloadType; + } + + public void setPayloadType(int payloadType) { + this.payloadType = payloadType; + } + + public String getEncryptedPayload() { + return encryptedPayload; + } + + public void setEncryptedPayload(String encryptedPayload) { + this.encryptedPayload = encryptedPayload; + } + + public String getRejectReason() { + return rejectReason; + } + + public void setRejectReason(String rejectReason) { + this.rejectReason = rejectReason; + } + + public long getExpiresAtMs() { + return expiresAtMs; + } + + public void setExpiresAtMs(long expiresAtMs) { + this.expiresAtMs = expiresAtMs; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Request.java new file mode 100644 index 0000000..12cc85f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Request.java @@ -0,0 +1,6 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_ListEspPairingRequests_Request extends Net_Request { +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Response.java new file mode 100644 index 0000000..31395f8 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_ListEspPairingRequests_Response.java @@ -0,0 +1,120 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +public class Net_ListEspPairingRequests_Response extends Net_Response { + private List requests = new ArrayList<>(); + + public List getRequests() { + return requests; + } + + public void setRequests(List requests) { + this.requests = requests; + } + + public static class PairingRequestItem { + private String pairingId; + private String state; + private String requesterSessionKey; + private int requesterSessionType; + private String requesterClientPlatform; + private int payloadType; + private String shortCode; + private String fingerprintB58; + private long createdAtMs; + private long expiresAtMs; + private boolean deliveredToHomeserver; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getRequesterSessionKey() { + return requesterSessionKey; + } + + public void setRequesterSessionKey(String requesterSessionKey) { + this.requesterSessionKey = requesterSessionKey; + } + + public int getRequesterSessionType() { + return requesterSessionType; + } + + public void setRequesterSessionType(int requesterSessionType) { + this.requesterSessionType = requesterSessionType; + } + + public String getRequesterClientPlatform() { + return requesterClientPlatform; + } + + public void setRequesterClientPlatform(String requesterClientPlatform) { + this.requesterClientPlatform = requesterClientPlatform; + } + + public int getPayloadType() { + return payloadType; + } + + public void setPayloadType(int payloadType) { + this.payloadType = payloadType; + } + + public String getShortCode() { + return shortCode; + } + + public void setShortCode(String shortCode) { + this.shortCode = shortCode; + } + + public String getFingerprintB58() { + return fingerprintB58; + } + + public void setFingerprintB58(String fingerprintB58) { + this.fingerprintB58 = fingerprintB58; + } + + public long getCreatedAtMs() { + return createdAtMs; + } + + public void setCreatedAtMs(long createdAtMs) { + this.createdAtMs = createdAtMs; + } + + public long getExpiresAtMs() { + return expiresAtMs; + } + + public void setExpiresAtMs(long expiresAtMs) { + this.expiresAtMs = expiresAtMs; + } + + public boolean isDeliveredToHomeserver() { + return deliveredToHomeserver; + } + + public void setDeliveredToHomeserver(boolean deliveredToHomeserver) { + this.deliveredToHomeserver = deliveredToHomeserver; + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Request.java new file mode 100644 index 0000000..8d290c5 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Request.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_RejectEspPairing_Request extends Net_Request { + private String pairingId; + private String reason; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Response.java new file mode 100644 index 0000000..59b06f3 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_RejectEspPairing_Response.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_RejectEspPairing_Response extends Net_Response { + private String pairingId; + private String state; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Request.java new file mode 100644 index 0000000..c5568a8 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Request.java @@ -0,0 +1,60 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_StartEspPairing_Request extends Net_Request { + private String login; + private String passwordHash; + private String requesterSessionKey; + private Integer requesterSessionType; + private String requesterClientPlatform; + private Integer payloadType; + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public String getRequesterSessionKey() { + return requesterSessionKey; + } + + public void setRequesterSessionKey(String requesterSessionKey) { + this.requesterSessionKey = requesterSessionKey; + } + + public Integer getRequesterSessionType() { + return requesterSessionType; + } + + public void setRequesterSessionType(Integer requesterSessionType) { + this.requesterSessionType = requesterSessionType; + } + + public String getRequesterClientPlatform() { + return requesterClientPlatform; + } + + public void setRequesterClientPlatform(String requesterClientPlatform) { + this.requesterClientPlatform = requesterClientPlatform; + } + + public Integer getPayloadType() { + return payloadType; + } + + public void setPayloadType(Integer payloadType) { + this.payloadType = payloadType; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Response.java new file mode 100644 index 0000000..cc70d6f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_StartEspPairing_Response.java @@ -0,0 +1,60 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_StartEspPairing_Response extends Net_Response { + private String pairingId; + private String state; + private String shortCode; + private String fingerprintB58; + private long expiresAtMs; + private boolean trustedSessionOnline; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getShortCode() { + return shortCode; + } + + public void setShortCode(String shortCode) { + this.shortCode = shortCode; + } + + public String getFingerprintB58() { + return fingerprintB58; + } + + public void setFingerprintB58(String fingerprintB58) { + this.fingerprintB58 = fingerprintB58; + } + + public long getExpiresAtMs() { + return expiresAtMs; + } + + public void setExpiresAtMs(long expiresAtMs) { + this.expiresAtMs = expiresAtMs; + } + + public boolean isTrustedSessionOnline() { + return trustedSessionOnline; + } + + public void setTrustedSessionOnline(boolean trustedSessionOnline) { + this.trustedSessionOnline = trustedSessionOnline; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Request.java new file mode 100644 index 0000000..30ed3b5 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Request.java @@ -0,0 +1,33 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_UpsertEspPairingSettings_Request extends Net_Request { + private Boolean enabled; + private String passwordHash; + private Integer ttlSeconds; + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public Integer getTtlSeconds() { + return ttlSeconds; + } + + public void setTtlSeconds(Integer ttlSeconds) { + this.ttlSeconds = ttlSeconds; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Response.java new file mode 100644 index 0000000..ad2d883 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_UpsertEspPairingSettings_Response.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_UpsertEspPairingSettings_Response extends Net_Response { + private boolean enabled; + private int ttlSeconds; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public int getTtlSeconds() { + return ttlSeconds; + } + + public void setTtlSeconds(int ttlSeconds) { + this.ttlSeconds = ttlSeconds; + } +} diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java b/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java new file mode 100644 index 0000000..573429a --- /dev/null +++ b/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java @@ -0,0 +1,170 @@ +package test.it.cases; + +import test.it.utils.TestConfig; +import test.it.utils.json.JsonBuilders; +import test.it.utils.json.JsonParsers; +import test.it.utils.log.TestLog; +import test.it.utils.log.TestResult; +import test.it.utils.ws.WsSession; +import utils.crypto.Ed25519Util; +import shine.db.dao.SolanaUsersDAO; +import shine.db.entities.SolanaUserEntry; + +import java.time.Duration; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.*; + +public class IT_07_EspPairing { + + private static final String LOGIN = TestConfig.LOGIN(); + + public static void main(String[] args) { + TestLog.info("Standalone: при необходимости локально создаю тестового пользователя напрямую в БД"); + String summary = run(); + System.out.println(summary); + } + + public static String run() { + TestResult r = new TestResult("IT_07_EspPairing"); + Duration t = Duration.ofSeconds(5); + + try { + ensureUserSeeded(); + Session clientSession = createSession(LOGIN, 1, "Web", t); + + try (WsSession requesterWs = WsSession.open(); + WsSession clientWs = WsSession.open()) { + + sessionLogin2Steps(clientWs, clientSession, 1, "Web", t, r); + + String passwordHash = "argon2id$v=19$m=65536,t=2,p=1$test$esp_pairing_hash"; + String upsertResp = clientWs.call( + "UpsertEspPairingSettings", + JsonBuilders.upsertEspPairingSettings(true, passwordHash, 180), + t + ); + assertEquals(200, JsonParsers.status(upsertResp), "UpsertEspPairingSettings must be 200"); + + SessionMaterial requesterMaterial = newSessionMaterial(); + String startResp = requesterWs.call( + "StartEspPairing", + JsonBuilders.startEspPairing(LOGIN, passwordHash, requesterMaterial.sessionKey(), 1, "Android", 1), + t + ); + assertEquals(200, JsonParsers.status(startResp), "StartEspPairing must be 200"); + String pairingId = JsonParsers.payloadText(startResp, "pairingId"); + assertNotNull(pairingId, "pairingId must be present"); + assertEquals("created", JsonParsers.payloadText(startResp, "state")); + assertEquals(Boolean.TRUE, JsonParsers.payloadBoolean(startResp, "trustedSessionOnline")); + + String listResp = clientWs.call("ListEspPairingRequests", JsonBuilders.listEspPairingRequests(), t); + assertEquals(200, JsonParsers.status(listResp), "ListEspPairingRequests must be 200"); + assertTrue(listResp.contains(pairingId), "ListEspPairingRequests must contain created pairingId"); + + String approveResp = clientWs.call( + "ApproveEspPairing", + JsonBuilders.approveEspPairing(pairingId, "AQIDBA=="), + t + ); + assertEquals(200, JsonParsers.status(approveResp), "ApproveEspPairing must be 200"); + assertEquals("approved", JsonParsers.payloadText(approveResp, "state")); + + String statusResp = requesterWs.call( + "GetEspPairingStatus", + JsonBuilders.getEspPairingStatus(pairingId), + t + ); + assertEquals(200, JsonParsers.status(statusResp), "GetEspPairingStatus must be 200"); + assertEquals("approved", JsonParsers.payloadText(statusResp, "state")); + assertEquals("AQIDBA==", JsonParsers.payloadText(statusResp, "encryptedPayload")); + + String forbiddenResp = requesterWs.call( + "ListEspPairingRequests#anonymous", + JsonBuilders.listEspPairingRequests(), + t + ); + assertErrorFormat(forbiddenResp, "ListEspPairingRequests", "PAIRING_REQUIRES_AUTH_SESSION"); + + r.ok("ESP pairing: обычная доверенная сессия увидела запрос и подтвердила зашифрованный payload"); + } + } catch (Throwable e) { + r.fail("IT_07_EspPairing упал: " + e.getMessage()); + } + + return r.summaryLine(); + } + + private static Session createSession(String login, int sessionType, String clientPlatform, Duration t) { + try (WsSession ws = WsSession.open()) { + String nonceResp = ws.call("AuthChallenge", JsonBuilders.authChallenge(login), t); + assertEquals(200, JsonParsers.status(nonceResp)); + String authNonce = JsonParsers.authNonce(nonceResp); + assertNotNull(authNonce); + + SessionMaterial material = newSessionMaterial(); + String storagePwd = TestConfig.fakeStoragePwd(); + String createResp = ws.call( + "CreateAuthSession", + JsonBuilders.createAuthSessionV2(login, authNonce, storagePwd, material.sessionKey(), sessionType, clientPlatform), + t + ); + assertEquals(200, JsonParsers.status(createResp), "CreateAuthSession must be 200"); + String sessionId = JsonParsers.sessionId(createResp); + assertNotNull(sessionId); + return new Session(sessionId, material.sessionKey(), material.sessionPrivKey(), storagePwd); + } + } + + private static void sessionLogin2Steps(WsSession ws, + Session s, + int sessionType, + String clientPlatform, + Duration t, + TestResult r) { + String chResp = ws.call("SessionChallenge", JsonBuilders.sessionChallenge(s.sessionId()), t); + assertEquals(200, JsonParsers.status(chResp)); + String nonce = JsonParsers.sessionNonce(chResp); + assertNotNull(nonce); + + String loginResp = ws.call( + "SessionLogin", + JsonBuilders.sessionLogin(s.sessionId(), s.sessionKey(), nonce, s.sessionPrivKey(), sessionType, clientPlatform), + t + ); + assertEquals(200, JsonParsers.status(loginResp)); + assertEquals(s.storagePwd(), JsonParsers.storagePwd(loginResp)); + r.ok("SessionLogin OK for sessionType=" + sessionType); + } + + private static void assertErrorFormat(String resp, String op, String code) { + int status = JsonParsers.status(resp); + assertFalse(status >= 200 && status < 300, "Expected non-2xx status: " + resp); + assertEquals(Boolean.FALSE, JsonParsers.ok(resp), "Expected ok=false: " + resp); + assertEquals(op, JsonParsers.op(resp), "Unexpected op: " + resp); + assertEquals(code, JsonParsers.errorCode(resp), "Unexpected error code: " + resp); + } + + private static SessionMaterial newSessionMaterial() { + byte[] sessionPrivKey = Ed25519Util.generatePrivateKey(); + byte[] sessionPubKey = Ed25519Util.derivePublicKey(sessionPrivKey); + String sessionKey = "ed25519/" + Base64.getEncoder().encodeToString(sessionPubKey); + return new SessionMaterial(sessionKey, sessionPrivKey); + } + + private static void ensureUserSeeded() throws Exception { + if (SolanaUsersDAO.getInstance().existsByLogin(LOGIN)) { + return; + } + SolanaUserEntry entry = new SolanaUserEntry(); + entry.setLogin(LOGIN); + entry.setBlockchainName(TestConfig.getBlockchainName(LOGIN)); + entry.setSolanaKey(TestConfig.solanaPublicKeyB64(LOGIN)); + entry.setBlockchainKey(TestConfig.blockchainPublicKeyB64(LOGIN)); + entry.setDeviceKey(TestConfig.devicePublicKeyB64(LOGIN)); + SolanaUsersDAO.getInstance().insert(entry); + } + + private record Session(String sessionId, String sessionKey, byte[] sessionPrivKey, String storagePwd) {} + private record SessionMaterial(String sessionKey, byte[] sessionPrivKey) {} +} diff --git a/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java b/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java index 6524bed..9c1c9ce 100644 --- a/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java +++ b/SHiNE-server/src/test/java/test/it/runner/IT_RunAllMain.java @@ -7,6 +7,7 @@ import test.it.cases.IT_03_AddBlock_NoAuth; import test.it.cases.IT_04_UserParams_NoAuth; import test.it.cases.IT_05_UserConnections; import test.it.cases.IT_06_ChannelsApi; +import test.it.cases.IT_07_EspPairing; import test.it.cases.Seed_TestDataPopulation; import test.it.utils.log.TestLog; @@ -61,9 +62,12 @@ public class IT_RunAllMain { String s6 = IT_06_ChannelsApi.run(); summaries.add(s6); if (s6.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } - String s7 = Seed_TestDataPopulation.run(); summaries.add(s7); + String s7 = IT_07_EspPairing.run(); summaries.add(s7); if (s7.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } + String s8 = Seed_TestDataPopulation.run(); summaries.add(s8); + if (s8.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } + return finish(summaries, failed); } diff --git a/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java b/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java index 6e88898..f7ef249 100644 --- a/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java +++ b/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java @@ -137,6 +137,10 @@ public final class JsonBuilders { // preimage = "AUTH_CREATE_SESSION:" + login + ":" + sessionKey + ":" + storagePwd + ":" + timeMs + ":" + authNonce public static String createAuthSessionV2(String login, String authNonce, String storagePwd, String sessionKey) { + return createAuthSessionV2(login, authNonce, storagePwd, sessionKey, 1, "IT"); + } + + public static String createAuthSessionV2(String login, String authNonce, String storagePwd, String sessionKey, int sessionType, String clientPlatform) { long timeMs = System.currentTimeMillis(); byte[] devicePriv = TestConfig.getDevicePrivatKey(login); @@ -156,6 +160,8 @@ public final class JsonBuilders { "authNonce": "%s", "deviceKey": "%s", "signatureB64": "%s", + "sessionType": %d, + "clientPlatform": "%s", "clientInfo": "%s" } } @@ -168,6 +174,8 @@ public final class JsonBuilders { authNonce, deviceKey, sigB64, + sessionType, + clientPlatform == null ? "" : clientPlatform, TestConfig.TEST_CLIENT_INFO ); } @@ -192,6 +200,10 @@ public final class JsonBuilders { // preimage = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce public static String sessionLogin(String sessionId, String sessionKey, String nonce, byte[] sessionPrivKey) { + return sessionLogin(sessionId, sessionKey, nonce, sessionPrivKey, 1, "IT"); + } + + public static String sessionLogin(String sessionId, String sessionKey, String nonce, byte[] sessionPrivKey, int sessionType, String clientPlatform) { long timeMs = System.currentTimeMillis(); String sigB64 = signSessionLogin(sessionId, timeMs, nonce, sessionPrivKey); @@ -203,12 +215,14 @@ public final class JsonBuilders { "payload": { "sessionId": "%s", "sessionKey": "%s", + "sessionType": %d, + "clientPlatform": "%s", "timeMs": %d, "signatureB64": "%s", "clientInfo": "%s" } } - """.formatted(requestId, sessionId, sessionKey, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO); + """.formatted(requestId, sessionId, sessionKey, sessionType, clientPlatform == null ? "" : clientPlatform, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO); } // ---------------- ListSessions ---------------- @@ -242,6 +256,96 @@ public final class JsonBuilders { """.formatted(requestId, sessionId, timeMs, signatureB64); } + public static String upsertEspPairingSettings(boolean enabled, String passwordHash, int ttlSeconds) { + String requestId = TestIds.next("esp-set"); + return """ + { + "op": "UpsertEspPairingSettings", + "requestId": "%s", + "payload": { + "enabled": %s, + "passwordHash": "%s", + "ttlSeconds": %d + } + } + """.formatted(requestId, enabled, passwordHash == null ? "" : passwordHash, ttlSeconds); + } + + public static String startEspPairing(String login, + String passwordHash, + String requesterSessionKey, + int requesterSessionType, + String requesterClientPlatform, + int payloadType) { + String requestId = TestIds.next("esp-start"); + return """ + { + "op": "StartEspPairing", + "requestId": "%s", + "payload": { + "login": "%s", + "passwordHash": "%s", + "requesterSessionKey": "%s", + "requesterSessionType": %d, + "requesterClientPlatform": "%s", + "payloadType": %d + } + } + """.formatted(requestId, login, passwordHash, requesterSessionKey, requesterSessionType, requesterClientPlatform == null ? "" : requesterClientPlatform, payloadType); + } + + public static String listEspPairingRequests() { + String requestId = TestIds.next("esp-list"); + return """ + { + "op": "ListEspPairingRequests", + "requestId": "%s", + "payload": {} + } + """.formatted(requestId); + } + + public static String approveEspPairing(String pairingId, String encryptedPayload) { + String requestId = TestIds.next("esp-approve"); + return """ + { + "op": "ApproveEspPairing", + "requestId": "%s", + "payload": { + "pairingId": "%s", + "encryptedPayload": "%s" + } + } + """.formatted(requestId, pairingId, encryptedPayload); + } + + public static String rejectEspPairing(String pairingId, String reason) { + String requestId = TestIds.next("esp-reject"); + return """ + { + "op": "RejectEspPairing", + "requestId": "%s", + "payload": { + "pairingId": "%s", + "reason": "%s" + } + } + """.formatted(requestId, pairingId, reason == null ? "" : reason); + } + + public static String getEspPairingStatus(String pairingId) { + String requestId = TestIds.next("esp-status"); + return """ + { + "op": "GetEspPairingStatus", + "requestId": "%s", + "payload": { + "pairingId": "%s" + } + } + """.formatted(requestId, pairingId); + } + // ---------------- ListSubscribedChannels ---------------- public static String listSubscribedChannels(String login) { diff --git a/SHiNE-server/src/test/java/test/it/utils/json/JsonParsers.java b/SHiNE-server/src/test/java/test/it/utils/json/JsonParsers.java index 7309208..16d7d46 100644 --- a/SHiNE-server/src/test/java/test/it/utils/json/JsonParsers.java +++ b/SHiNE-server/src/test/java/test/it/utils/json/JsonParsers.java @@ -147,6 +147,19 @@ public final class JsonParsers { return getPayloadText(json, field); } + public static Boolean payloadBoolean(String json, String field) { + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload != null && payload.has(field) && !payload.get(field).isNull()) { + return payload.get(field).asBoolean(); + } + return null; + } catch (Exception e) { + return null; + } + } + public static List sessionIds(String json) { List res = new ArrayList<>(); try { @@ -315,4 +328,19 @@ public final class JsonParsers { return -1; } } + + public static String firstArrayItemText(String json, String arrayField, String field) { + try { + JsonNode root = MAPPER.readTree(json); + JsonNode payload = root.get("payload"); + if (payload == null) return null; + JsonNode arr = payload.get(arrayField); + if (arr == null || !arr.isArray() || arr.isEmpty()) return null; + JsonNode item = arr.get(0); + if (item == null || !item.has(field) || item.get(field).isNull()) return null; + return item.get(field).asText(); + } catch (Exception e) { + return null; + } + } } diff --git a/VERSION.properties b/VERSION.properties index d8a6c6e..d2707ae 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.190 -server.version=1.2.179 +client.version=1.2.191 +server.version=1.2.180