From 5c92b6a734f281e85697b1ace1567f42ccf47d40973cff0958ec0b8e368e6c94 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Mon, 22 Jun 2026 21:57:09 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20PDA=20=D0=BD=D0=B0=20client.key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DAO_запуск/README.md | 2 +- DOC/libs/shine-server-bd/DOC.md | 2 +- DOC/Описание БД.md | 4 +- Dev_Docs/API/01_User_Registration_API.md | 4 +- Dev_Docs/API/02_Authentication_API.md | 26 +- Dev_Docs/API/10_User_Params_API.md | 8 +- ...26-06-05_esp32_hardware_wallet_device_session.md | 2 +- Dev_Docs/Keys/DERIVATION.md | 16 +- Dev_Docs/Keys/README.md | 28 +- .../2026-06-19_2015_esp32_pairing_requests.md | 2 +- ...esp32_wallet_selector_and_extension_rpc.md | 6 +- ..._esp32_english_ui_and_trusted_login_fix.md | 2 +- ...allet_provider_and_esp_sign_transaction.md | 2 +- Dev_Docs/Solana/user_pda/README.md | 118 +- .../details/accounts_and_money_flow.md | 2 +- .../details/shine_users.md | 2 +- .../schemes/architecture.svg | 2 +- .../Solana-audit-3-by-Claude-12июня2026.md | 6 +- .../README.md | 6 +- .../ESP_Pairing_и_режимы_подключения.md | 8 +- .../Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md | 10 +- .../Формат_взаимодействия_внешнего_кошелька_и_ESP32.md | 8 +- ESP32/AGENTS.md | 6 + .../argon2_sd_test/argon2_sd_test.ino | 6 +- .../shine_homeserver_main.ino | 130 +- .../shine_homeserver_ui.ino | 34 +- .../shine_homeserver_ui_nav_minimal_spec.md | 8 +- .../reference/shine_homeserver_ui_spec.md | 12 +- SHiNE-browser-plugin-wallet/README.md | 2 +- SHiNE-browser-plugin-wallet/background.js | 10 +- .../js/lib/shine-server-resolver.js | 11 +- SHiNE-browser-plugin-wallet/popup.html | 2 +- SHiNE-browser-plugin-wallet/popup.js | 4 +- .../shine-server-blockchain/all_files.txt | 2884 ---------- .../shine-server-blockchain/concat_to_file.sh | 20 - .../shine-server-crypto/concat_to_file.sh | 20 - .../shine-server-crypto/src/concat_to_file.sh | 20 - SHiNE-server/shine-server-db/all_files.txt | 2832 ---------- .../shine-server-db/concat_to_file.sh | 20 - .../java/shine/db/DatabaseInitializer.java | 6 +- .../java/shine/db/dao/SolanaUsersDAO.java | 14 +- .../main/java/shine/db/dao/UserCreateDAO.java | 6 +- .../main/java/shine/db/dao/UserParamsDAO.java | 14 +- .../shine/db/entities/SolanaUserEntry.java | 20 +- .../shine/db/entities/UserParamEntry.java | 12 +- .../shine-server-log/concat_to_file.sh | 20 - .../shine-server-net-protocol/all_files.txt | 4739 ---------------- .../concat_to_file.sh | 20 - .../src/main/java/server/all_files.txt | 4742 ----------------- .../src/main/java/server/concat_to_file.sh | 20 - .../ws_protocol/JSON/ConnectionContext.java | 4 +- .../logic/ws_protocol/JSON/all_files.txt | 4548 ---------------- .../logic/ws_protocol/JSON/concat_to_file.sh | 20 - .../ws_protocol/JSON/entyties/all_files.txt | 140 - .../JSON/entyties/concat_to_file.sh | 20 - .../ws_protocol/JSON/handlers/all_files.txt | 3475 ------------ .../auth/Net_AuthChallenge_Handler.java | 4 +- .../auth/Net_CreateAuthSession__Handler.java | 40 +- .../auth/SolanaUserPdaImportService.java | 14 +- .../JSON/handlers/auth/all_files.txt | 1439 ----- .../JSON/handlers/auth/concat_to_file.sh | 20 - .../auth/entyties/AUTH_SESSION_NEW.md | 10 +- .../Net_CreateAuthSession_Request.java | 18 +- .../handlers/blockchain/concat_to_file.sh | 20 - .../JSON/handlers/concat_to_file.sh | 20 - .../JSON/handlers/connections/all_files.txt | 180 - .../handlers/connections/concat_to_file.sh | 20 - .../tempToTest/Net_GetUser_Handler.java | 2 +- .../JSON/handlers/tempToTest/all_files.txt | 240 - .../handlers/tempToTest/concat_to_file.sh | 20 - .../entyties/Net_AddUser_Request.java | 8 +- .../entyties/Net_GetUser_Response.java | 8 +- .../userParams/Net_GetUserParam_Handler.java | 2 +- .../Net_ListUserParams_Handler.java | 2 +- .../Net_UpsertUserParam_Handler.java | 28 +- .../JSON/handlers/userParams/all_files.txt | 640 --- .../handlers/userParams/concat_to_file.sh | 20 - .../entyties/Net_GetUserParam_Response.java | 8 +- .../entyties/Net_ListUserParams_Response.java | 8 +- .../entyties/Net_UpsertUserParam_Request.java | 8 +- .../Net_SendDirectMessage_Handler.java | 4 +- .../JSON/messages/SignedMessagesCore.java | 2 +- .../JSON/utils/AuthSignatures.java | 6 +- SHiNE-server/src/main/all_files.txt | 552 -- SHiNE-server/src/main/concat_to_file.sh | 20 - SHiNE-server/src/main/concat_to_file2.sh | 38 - SHiNE-server/src/main/запросы.sh | 76 - SHiNE-server/src/test/addblocks.sh | 39 - SHiNE-server/src/test/all_files.txt | 2951 ---------- SHiNE-server/src/test/concat_to_file.sh | 20 - .../java/test/it/cases/IT_01_AddUser.java | 14 +- .../java/test/it/cases/IT_02_Sessions.java | 6 +- .../it/cases/IT_04_UserParams_NoAuth.java | 34 +- .../java/test/it/cases/IT_07_EspPairing.java | 2 +- .../it/cases/SeedDataPopulationHelper.java | 34 +- .../test/java/test/it/utils/TestConfig.java | 20 +- .../java/test/it/utils/json/JsonBuilders.java | 24 +- .../java/test/it/utils/json/JsonParsers.java | 4 +- .../01-ТЗ-web-push-direct-message.md | 6 +- VERSION.properties | 4 +- shine-UI/js/app.js | 8 +- shine-UI/js/components/avatar-wizard.js | 4 +- shine-UI/js/mock-data.js | 2 +- shine-UI/js/pages/connect-device-view.js | 6 +- shine-UI/js/pages/developer-settings-view.js | 4 +- shine-UI/js/pages/device-pairing-view.js | 14 +- shine-UI/js/pages/device-qr-view.js | 4 +- shine-UI/js/pages/key-storage-view.js | 4 +- shine-UI/js/pages/login-camera-view.js | 4 +- shine-UI/js/pages/register-view.js | 2 +- .../js/pages/registration-draft-keys-view.js | 28 +- shine-UI/js/pages/registration-faq-view.js | 4 +- .../js/pages/registration-payment-view.js | 6 +- shine-UI/js/pages/show-keys-view.js | 8 +- shine-UI/js/pages/topup-view.js | 10 +- shine-UI/js/pages/wallet-view.js | 34 +- .../js/services/arweave-wallet-service.js | 22 +- shine-UI/js/services/auth-service.js | 87 +- ...evice-key-utils.js => client-key-utils.js} | 12 +- shine-UI/js/services/crypto-utils.js | 16 +- .../js/services/device-pairing-service.js | 2 +- .../js/services/qr-key-transfer-service.js | 8 +- shine-UI/js/services/sawd-v1.js | 28 +- .../js/services/shine-user-pda-service.js | 86 +- shine-UI/js/services/solana-wallet-service.js | 18 +- shine-UI/js/state.js | 2 +- shine-UI/server-ui.html | 4 +- shine-UI/server-ui/js/server-ui-shared.js | 41 +- .../server-ui/js/update-server-pda-page.js | 4 +- .../formats/shine-user-pda-format-v.1.0.md | 117 +- .../shine/doc/programs/shine_users.md | 67 +- .../shine/programs/shine_users/src/lib.rs | 52 +- shine-solana/shine/tests/shine.ts | 22 +- 133 files changed, 941 insertions(+), 30531 deletions(-) delete mode 100644 SHiNE-server/shine-server-blockchain/all_files.txt delete mode 100755 SHiNE-server/shine-server-blockchain/concat_to_file.sh delete mode 100755 SHiNE-server/shine-server-crypto/concat_to_file.sh delete mode 100755 SHiNE-server/shine-server-crypto/src/concat_to_file.sh delete mode 100644 SHiNE-server/shine-server-db/all_files.txt delete mode 100755 SHiNE-server/shine-server-db/concat_to_file.sh delete mode 100755 SHiNE-server/shine-server-log/concat_to_file.sh delete mode 100644 SHiNE-server/shine-server-net-protocol/all_files.txt delete mode 100755 SHiNE-server/shine-server-net-protocol/concat_to_file.sh delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/all_files.txt delete mode 100755 SHiNE-server/shine-server-net-protocol/src/main/java/server/concat_to_file.sh delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/all_files.txt delete mode 100755 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/concat_to_file.sh delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/all_files.txt delete mode 100755 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/concat_to_file.sh delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/all_files.txt delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/all_files.txt delete mode 100755 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/concat_to_file.sh delete mode 100755 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/concat_to_file.sh delete mode 100755 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/concat_to_file.sh delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/all_files.txt delete mode 100755 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/concat_to_file.sh delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/all_files.txt delete mode 100755 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/concat_to_file.sh delete mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/all_files.txt delete mode 100755 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/concat_to_file.sh delete mode 100644 SHiNE-server/src/main/all_files.txt delete mode 100755 SHiNE-server/src/main/concat_to_file.sh delete mode 100755 SHiNE-server/src/main/concat_to_file2.sh delete mode 100644 SHiNE-server/src/main/запросы.sh delete mode 100755 SHiNE-server/src/test/addblocks.sh delete mode 100644 SHiNE-server/src/test/all_files.txt delete mode 100755 SHiNE-server/src/test/concat_to_file.sh rename shine-UI/js/services/{device-key-utils.js => client-key-utils.js} (70%) diff --git a/DAO_запуск/README.md b/DAO_запуск/README.md index ac7fd93..6969f65 100644 --- a/DAO_запуск/README.md +++ b/DAO_запуск/README.md @@ -134,7 +134,7 @@ Что сделать: -- продумать и реализовать смену `root key`, `device key`, `blockchain key`; +- продумать и реализовать смену `root key`, `client key`, `blockchain key`; - описать ограничения, кто и в каком сценарии может менять каждый тип ключа; - продумать, как не потерять доступ и как обновлять доверие к новым ключам. diff --git a/DOC/libs/shine-server-bd/DOC.md b/DOC/libs/shine-server-bd/DOC.md index c95247f..aae2dfa 100644 --- a/DOC/libs/shine-server-bd/DOC.md +++ b/DOC/libs/shine-server-bd/DOC.md @@ -8,7 +8,7 @@ shine.db.SqliteDbController — один вход в БД: читает db.path, shine.db.DatabaseInitializer — разовая сборка схемы (таблицы + индексы). -shine.db.entities.* — POJO-модели строк таблиц (без логики, только поля/геттеры/сеттеры + иногда удобные методы вроде getDeviceKeyByte()). +shine.db.entities.* — POJO-модели строк таблиц (без логики, только поля/геттеры/сеттеры + иногда удобные методы вроде getClientKeyByte()). shine.db.dao.* — DAO по таблицам: ActiveSessionsDAO, SolanaUsersDAO, UserParamsDAO, IpGeoCacheDAO, BlockchainStateDAO, BlocksDAO; плюс “сервисные” DAO: UserCreateDAO — атомарная регистрация пользователя в транзакции (BEGIN IMMEDIATE + rollback/commit). diff --git a/DOC/Описание БД.md b/DOC/Описание БД.md index ac06e07..684098a 100644 --- a/DOC/Описание БД.md +++ b/DOC/Описание БД.md @@ -38,7 +38,7 @@ message_stats ⭐ solana_users login — TEXT PK — уникальный логин пользователя -device_key — TEXT NOT NULL — публичный ключ устройства (Base64(32) / HEX(64)) +client_key — TEXT NOT NULL — публичный ключ устройства (Base64(32) / HEX(64)) solana_key — TEXT NULL — публичный ключ Solana-аккаунта active_sessions @@ -61,7 +61,7 @@ login — TEXT NOT NULL, FK → solana_users(login) param — TEXT NOT NULL time_ms — INTEGER NOT NULL value — TEXT NOT NULL -device_key — TEXT NULL +client_key — TEXT NULL signature — TEXT NULL Ограничение: diff --git a/Dev_Docs/API/01_User_Registration_API.md b/Dev_Docs/API/01_User_Registration_API.md index a975d79..2f84d7e 100644 --- a/Dev_Docs/API/01_User_Registration_API.md +++ b/Dev_Docs/API/01_User_Registration_API.md @@ -35,7 +35,7 @@ "blockchainName": "anya-001", "solanaKey": "BASE64_32_PUBLIC_KEY", "blockchainKey": "BASE64_32_PUBLIC_KEY", - "deviceKey": "BASE64_32_PUBLIC_KEY", + "clientKey": "BASE64_32_PUBLIC_KEY", "bchLimit": 1000000 } } @@ -99,7 +99,7 @@ "blockchainName": "anya-001", "solanaKey": "BASE64_32_PUBLIC_KEY", "blockchainKey": "BASE64_32_PUBLIC_KEY", - "deviceKey": "BASE64_32_PUBLIC_KEY", + "clientKey": "BASE64_32_PUBLIC_KEY", "serverLastGlobalNumber": 128, "serverLastGlobalHash": "4f...ab", "serverBlockchainSizeBytes": 45212, diff --git a/Dev_Docs/API/02_Authentication_API.md b/Dev_Docs/API/02_Authentication_API.md index 65f4024..1ace107 100644 --- a/Dev_Docs/API/02_Authentication_API.md +++ b/Dev_Docs/API/02_Authentication_API.md @@ -11,7 +11,7 @@ Логика раздела такая: -- сначала клиент либо начинает создание новой сессии через `deviceKey`; +- сначала клиент либо начинает создание новой сессии через `clientKey`; - либо начинает вход в уже созданную сессию через `sessionKey`; - сервер на первом шаге выдаёт challenge/nonce; - на втором шаге клиент присылает подписанный ответ; @@ -55,7 +55,7 @@ 2. Вход в существующую сессию: `SessionChallenge` -> `SessionLogin` -`deviceKey` используется для создания новой сессии. +`clientKey` используется для создания новой сессии. `sessionKey` используется для входа в уже созданную сессию. @@ -119,7 +119,7 @@ ed25519/BASE64_PUBLIC_KEY "storagePwd": "BASE64_OR_APP_SPECIFIC_SECRET", "timeMs": 1774600000123, "authNonce": "nonce", - "deviceKey": "BASE64_DEVICE_PUBLIC_KEY", + "clientKey": "BASE64_DEVICE_PUBLIC_KEY", "signatureB64": "BASE64_SIGNATURE", "sessionType": 1, "clientPlatform": "Web", @@ -138,15 +138,15 @@ AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} Перед проверкой подписи сервер должен: -1. взять актуальный `solana_users.device_key`; -2. сравнить его с `payload.deviceKey`; +1. взять актуальный `solana_users.client_key`; +2. сравнить его с `payload.clientKey`; 3. только потом проверять подпись. -Если ключ не совпадает, сервер возвращает ошибку `DEVICE_KEY_NOT_ACTUAL`. +Если `clientKey` не совпадает, сервер возвращает ошибку `DEVICE_KEY_NOT_ACTUAL`. На будущее: -- для ротации `device_key` желательно добавить перепроверку через Solana. +- для ротации `client_key` желательно добавить перепроверку через Solana. ### Успешный ответ @@ -172,15 +172,15 @@ AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} - `501 / NO_LOGIN` — у пользователя на сервере не заполнен `login`. - `400 / EMPTY_STORAGE_PWD` — пустой `storagePwd`. - `400 / EMPTY_SESSION_KEY` — пустой `sessionKey`. -- `422 / UNSUPPORTED_KEY_ALGORITHM` — префикс алгоритма в `sessionKey` или `deviceKey` не поддерживается текущим сервером. -- `400 / BAD_BASE64` — неверный Base64 в `sessionKey`, `deviceKey` или `signatureB64`. +- `422 / UNSUPPORTED_KEY_ALGORITHM` — префикс алгоритма в `sessionKey` или `clientKey` не поддерживается текущим сервером. +- `400 / BAD_BASE64` — неверный Base64 в `sessionKey`, `clientKey` или `signatureB64`. - `400 / EMPTY_SIGNATURE` — пустая подпись. - `400 / TIME_SKEW` — время клиента отличается от серверного больше допустимого окна. -- `400 / NO_DEVICE_KEY` — у пользователя в БД отсутствует `deviceKey`. +- `400 / NO_DEVICE_KEY` — у пользователя в БД отсутствует `clientKey`. - `400 / EMPTY_AUTH_NONCE` — пустой `authNonce`. - `400 / AUTH_NONCE_MISMATCH` — `authNonce` не соответствует значению из `AuthChallenge`. -- `400 / EMPTY_DEVICE_KEY` — в запросе не передан `deviceKey`. -- `422 / DEVICE_KEY_NOT_ACTUAL` — `deviceKey` не совпадает с актуальной версией на сервере. +- `400 / EMPTY_DEVICE_KEY` — в запросе не передан `clientKey`. +- `422 / DEVICE_KEY_NOT_ACTUAL` — `clientKey` не совпадает с актуальной версией на сервере. - `422 / BAD_SIGNATURE` — подпись не прошла проверку. - `460 / SESSION_TYPE_MISMATCH` — `sessionType` не совпадает с типом сессии, уже опубликованным для этого `sessionKey` в Solana PDA. - `501 / SESSION_TYPE_PDA_CHECK_FAILED` — сервер не смог проверить `sessionType` по Solana PDA. @@ -314,7 +314,7 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} В этом потоке: -- новое устройство не владеет `deviceKey` и не проходит обычный `CreateAuthSession`; +- новое устройство не владеет `clientKey` и не проходит обычный `CreateAuthSession`; - пароль проверяется сервером только как фильтр; - решение об одобрении принимает уже авторизованная доверенная сессия пользователя; - сервер не расшифровывает `encryptedPayload` и не становится источником приватных ключей. diff --git a/Dev_Docs/API/10_User_Params_API.md b/Dev_Docs/API/10_User_Params_API.md index 8b5e65e..f64ef99 100644 --- a/Dev_Docs/API/10_User_Params_API.md +++ b/Dev_Docs/API/10_User_Params_API.md @@ -21,7 +21,7 @@ "param": "display_name", "time_ms": 1774700000123, "value": "Alice", - "device_key": "BASE64_DEVICE_PUBLIC_KEY", + "client_key": "BASE64_DEVICE_PUBLIC_KEY", "signature": "BASE64_SIGNATURE" } } @@ -76,7 +76,7 @@ "param": "display_name", "time_ms": 1774700000123, "value": "Alice", - "device_key": "BASE64_DEVICE_PUBLIC_KEY", + "client_key": "BASE64_DEVICE_PUBLIC_KEY", "signature": "BASE64_SIGNATURE" } } @@ -116,7 +116,7 @@ "param": "display_name", "time_ms": 1774700000123, "value": "Alice", - "device_key": "BASE64_DEVICE_PUBLIC_KEY", + "client_key": "BASE64_DEVICE_PUBLIC_KEY", "signature": "BASE64_SIGNATURE" } ] @@ -126,4 +126,4 @@ ## Примечание -Имена JSON-полей `time_ms` и `device_key` сейчас соответствуют Java-модели ответа/запроса и должны передаваться именно в таком виде. +Имена JSON-полей `time_ms` и `client_key` сейчас соответствуют Java-модели ответа/запроса и должны передаваться именно в таком виде. diff --git a/Dev_Docs/Future_Features/dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md b/Dev_Docs/Future_Features/dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md index b86d377..c98a3ca 100644 --- a/Dev_Docs/Future_Features/dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md +++ b/Dev_Docs/Future_Features/dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md @@ -22,7 +22,7 @@ ESP32 становится аппаратным HSM (hardware security module): ### ESP32 (основная работа) - [ ] Инициализация WiFi (SSID/пароль в NVS) - [ ] WebSocket-клиент (`WebSocketsClient`) — постоянное соединение с сервером -- [ ] Авторизация на сервере: `AuthChallenge` → `CreateAuthSession` через `deviceKey` (уже есть в NVS), сохранить `sessionId` в NVS +- [ ] Авторизация на сервере: `AuthChallenge` → `CreateAuthSession` через `clientKey` (уже есть в NVS), сохранить `sessionId` в NVS - [ ] Обработчик входящих WebSocket-событий: JSON-парсинг, диспетчер по типу - [ ] Новые UI-экраны: «Разрешить сессию?» и «Подписать?» с кнопками Да/Нет - [ ] Расширенное хранилище ключей в NVS (произвольные именованные ключи сверх базовых трёх) diff --git a/Dev_Docs/Keys/DERIVATION.md b/Dev_Docs/Keys/DERIVATION.md index b342522..77a864e 100644 --- a/Dev_Docs/Keys/DERIVATION.md +++ b/Dev_Docs/Keys/DERIVATION.md @@ -56,7 +56,7 @@ seed(32) = SHA-256(material) |------|---------|---------------------| | root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). | | blockchain | `bch.key` | Подписывает `LastBlockState` персонального блокчейна (`blockchain_public_key`). | -| device / **Solana** | `dev.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(devicePub)`. См. §3. | +| device / **Solana** | `client.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(clientPub)`. См. §3. | | homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. | Полные роли каждого ключа — в `Dev_Docs/Keys/README.md`. @@ -67,16 +67,16 @@ seed(32) = SHA-256(material) Отдельного «солана-ключа» нет. На Solana работают два ключа: -- **`dev.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(devicePub)`. +- **`client.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(clientPub)`. Этим ключом оплачиваются и подписываются `create_user_pda` / `update_user_pda`. Пополнять SOL нужно именно на этот адрес. - **`root.key` — авторитет записи**, подписывает unsigned-часть PDA через Ed25519-инструкцию, но **не** является fee payer. Соответствует формату PDA `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` §2.1 -(«create/update оплачиваются с `device_key`», «root_key — не fee payer»). +(«create/update оплачиваются с `client_key`», «root_key — не fee payer»). Кратко про роли на Solana: `root.key` — это **главный (master) ключ**: им управляют PDA-записью -(`create/update`) и через это можно заменить все остальные ключи; `dev.key` — это **пополняемый +(`create/update`) и через это можно заменить все остальные ключи; `client.key` — это **пополняемый кошелёк и плательщик комиссий**. Полное описание ролей — `Dev_Docs/Keys/README.md`. --- @@ -118,9 +118,9 @@ homeserver.key:home-b -> ключ B - `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147–160). ### Solana-ключ / адрес кошелька (UI) -- `shine-UI/js/pages/registration-payment-view.js` — `deriveUserWalletAddress`: адрес = `base58(devicePub)` (~113). -- `shine-UI/js/pages/topup-view.js` — `deviceWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.devicePair`. - Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `dev.key`, мимо `masterSecret`) удалён. +- `shine-UI/js/pages/registration-payment-view.js` — `deriveUserWalletAddress`: адрес = `base58(clientPub)` (~113). +- `shine-UI/js/pages/topup-view.js` — `clientWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.clientPair`. + Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `client.key`, мимо `masterSecret`) удалён. ### Деривация ключей (прошивка ESP32) - `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino` @@ -131,7 +131,7 @@ homeserver.key:home-b -> ключ B ### Формат PDA (куда попадают ключи) - `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` - — `RootKeyBlock` §6, `DeviceKeyBlock` §7, `blockchain_public_key` §9, `SessionsBlock`/`session_type=100` §13, оплата §2.1. + — `RootKeyBlock` §6, `ClientKeyBlock` §7, `blockchain_public_key` §9, `SessionsBlock`/`session_type=100` §13, оплата §2.1. ### Сервер (тестовый seed) - `SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java` `deriveKeysFromPassword` (~246) — diff --git a/Dev_Docs/Keys/README.md b/Dev_Docs/Keys/README.md index 863a9da..4afb114 100644 --- a/Dev_Docs/Keys/README.md +++ b/Dev_Docs/Keys/README.md @@ -8,9 +8,9 @@ В SHiNE у пользователя есть несколько уровней ключей: -- `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `device key`). +- `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `client key`). - `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя. -- `device key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей. +- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей. - `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере. Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы. @@ -30,7 +30,7 @@ `root key` — это **главный (master) ключ** в следующем смысле: зная `root key`, можно управлять пользовательской PDA-записью в Solana (`create_user_pda` / `update_user_pda`) и тем самым **заменить все остальные ключи** пользователя (device, blockchain, homeserver). Поэтому компрометация `root key` равносильна компрометации всей личности пользователя. -Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `device key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `Dev_Docs/Keys/DERIVATION.md`, §3. +Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `client key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `Dev_Docs/Keys/DERIVATION.md`, §3. ## `blockchain key` @@ -50,9 +50,9 @@ Рабочая логика по умолчанию должна использовать последнюю актуальную ветку. Старые ветки остаются читаемыми и показывают историю смены ключей. -## `device key` +## `client key` -`device key` - общий ключ, который знают доверенные устройства пользователя. +`client key` - общий ключ, который знают доверенные устройства пользователя. Назначение: @@ -63,11 +63,11 @@ - derivation Arweave-кошелька; - оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу. -Arweave-кошелёк должен выводиться из `device key` по протоколу: +Arweave-кошелёк должен выводиться из `client key` по протоколу: - `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` -Если пользователь теряет только `device key`, в худшем случае ломается повседневная переписка и доступ конкретных устройств к ежедневным операциям. `root key` и `blockchain key` при правильной архитектуре остаются отдельно защищёнными. +Если пользователь теряет только `client key`, в худшем случае ломается повседневная переписка и доступ конкретных устройств к ежедневным операциям. `root key` и `blockchain key` при правильной архитектуре остаются отдельно защищёнными. ## `session key` @@ -83,7 +83,7 @@ Arweave-кошелёк должен выводиться из `device key` по - авторизация сессии на сервере; - привязка устройства к пользователю; - подтверждение запросов от конкретной сессии; -- доступ к зашифрованному `device key` после успешной авторизации. +- доступ к зашифрованному `client key` после успешной авторизации. Одна и та же сессия может быть пригодна для подключения к нескольким серверам пользователя, если архитектура конкретного пользователя это допускает. @@ -108,14 +108,14 @@ Arweave-кошелёк должен выводиться из `device key` по Обычное устройство обычно имеет: - собственный `session key`; -- зашифрованный `device key`, который открывается после авторизации; +- зашифрованный `client key`, который открывается после авторизации; - доступ к DM, звонкам и обычным пользовательским операциям. Доверенное серверное или аппаратное устройство может иметь: - `root key`; - `blockchain key`; -- `device key`; +- `client key`; - собственный `session key`. Такая сессия может подписывать операции повышенной важности по запросам пользователя. @@ -139,7 +139,7 @@ Self-message - это сообщение пользователя самому Входящее сообщение может быть зашифровано: -- `device key`; +- `client key`; - `session key`; - отдельным ключом конкретного чата; - другим ключом, который уже известен клиенту. @@ -158,12 +158,12 @@ Self-message - это сообщение пользователя самому ## Связанные документы -- `Dev_Docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`dev.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код). +- `Dev_Docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`client.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код). - `Dev_Docs/Personal_Messages/README.md` - текущая документация личных сообщений. - `Dev_Docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна. - `Dev_Docs/Solana_Architecture/README.md` - архитектура Solana-программ, PDA-счетов, DAO и движения средств. - `Dev_Docs/Инициализация_Solana_регистрации/README.md` - деплой и первичная инициализация Solana-регистрации. -- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` - derivation Arweave-кошелька из `device key`. +- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` - derivation Arweave-кошелька из `client key`. ## Что нужно уточнить перед реализацией @@ -172,5 +172,5 @@ Self-message - это сообщение пользователя самому - какие операции требуют `root key`, а какие достаточно подписывать `blockchain key`; - формат self-message-команд; - порядок перебора ключей при расшифровке входящих сообщений; -- правила ротации `device key` и восстановления доступа после потери устройства; +- правила ротации `client key` и восстановления доступа после потери устройства; - какие типы серверных и аппаратных сессий нужны в первой реализации. diff --git a/Dev_Docs/Pending_Features/2026-06-19_2015_esp32_pairing_requests.md b/Dev_Docs/Pending_Features/2026-06-19_2015_esp32_pairing_requests.md index 2eb2dbe..2966137 100644 --- a/Dev_Docs/Pending_Features/2026-06-19_2015_esp32_pairing_requests.md +++ b/Dev_Docs/Pending_Features/2026-06-19_2015_esp32_pairing_requests.md @@ -11,7 +11,7 @@ - на ESP32 после успешной homeserver-авторизации в `SETTINGS` появляется пункт `Device requests` первым; - `REFRESH` реально загружает активные заявки; - на экране видно две плитки, список листается вертикально; - - client-session заявка после `YES` подключается с передачей только `device key`; + - client-session заявка после `YES` подключается с передачей только `client key`; - wallet-session заявка после `YES` подключается без передачи ключей, через выпуск отдельной wallet-session; - `NO` отклоняет заявку и она исчезает из списка активных. diff --git a/Dev_Docs/Pending_Features/2026-06-21_1735_esp32_wallet_selector_and_extension_rpc.md b/Dev_Docs/Pending_Features/2026-06-21_1735_esp32_wallet_selector_and_extension_rpc.md index f5e30a2..416f383 100644 --- a/Dev_Docs/Pending_Features/2026-06-21_1735_esp32_wallet_selector_and_extension_rpc.md +++ b/Dev_Docs/Pending_Features/2026-06-21_1735_esp32_wallet_selector_and_extension_rpc.md @@ -2,20 +2,20 @@ - краткое описание: - в ESP32 заменён домашний блок `баланс + QR` на единый вход в экран кошелька; - - добавлен выбор активного кошелька `DeviceKey / RootKey / Custom`; + - добавлен выбор активного кошелька `ClientKey / RootKey / Custom`; - для browser extension добавлен первый RPC `get_wallet_public_key` через существующий `wallet-session` и `CallSignalToSession`; - в popup расширения добавлен запрос текущего кошелька и копирование `publicKeyBase58`. - что проверять: - на ESP32 после ввода секрета на главном экране видна кнопка `Кошелёк: ...`; - экран `WALLET` открывается и показывает текущий тип кошелька; - - экран `WALLET_SELECT` переключает `DeviceKey`, `RootKey` и `Custom`; + - экран `WALLET_SELECT` переключает `ClientKey`, `RootKey` и `Custom`; - для `Custom` открывается ввод имени и после сохранения derivation работает; - `Показать баланс кошелька` читает баланс именно активного кошелька; - `Показать QR-код кошелька` показывает QR и адрес именно активного кошелька; - browser extension после подключения wallet-session может запросить текущий кошелёк у ESP32; - extension показывает тип кошелька, полный `publicKeyBase58`, результат проверки через PDA и копирует ключ в буфер; - - для `dev.key` и `root.key` проверка через PDA даёт ожидаемое совпадение. + - для `client.key` и `root.key` проверка через PDA даёт ожидаемое совпадение. - ожидаемый результат: - активный кошелёк на ESP32 реально влияет на баланс, QR и ответ `get_wallet_public_key`; diff --git a/Dev_Docs/Pending_Features/2026-06-21_2045_esp32_english_ui_and_trusted_login_fix.md b/Dev_Docs/Pending_Features/2026-06-21_2045_esp32_english_ui_and_trusted_login_fix.md index 023531e..2a922b1 100644 --- a/Dev_Docs/Pending_Features/2026-06-21_2045_esp32_english_ui_and_trusted_login_fix.md +++ b/Dev_Docs/Pending_Features/2026-06-21_2045_esp32_english_ui_and_trusted_login_fix.md @@ -5,7 +5,7 @@ - Что проверять: 1. На ESP32 открыть `HOME`, `WALLET`, `SELECT WALLET`, `WALLET QR` и убедиться, что пользовательские строки на экране только на английском. - 2. Проверить сценарий выбора `DeviceKey`, `RootKey`, `Custom` и чтение баланса/QR после переключения. + 2. Проверить сценарий выбора `ClientKey`, `RootKey`, `Custom` и чтение баланса/QR после переключения. 3. В расширении открыть popup, запустить `Подключить` для логина, у которого ещё не настраивался trusted-device login. 4. Убедиться, что `StartTrustedDeviceLogin` больше не падает с `NullPointerException`. 5. После создания pairing-запроса проверить, что homeserver/UI может его увидеть и обработать как раньше. diff --git a/Dev_Docs/Pending_Features/2026-06-21_2235_wallet_provider_and_esp_sign_transaction.md b/Dev_Docs/Pending_Features/2026-06-21_2235_wallet_provider_and_esp_sign_transaction.md index 55771e1..1573d9f 100644 --- a/Dev_Docs/Pending_Features/2026-06-21_2235_wallet_provider_and_esp_sign_transaction.md +++ b/Dev_Docs/Pending_Features/2026-06-21_2235_wallet_provider_and_esp_sign_transaction.md @@ -12,7 +12,7 @@ 6. Проверить оба варианта: - `APPROVE` возвращает сайту подписанную транзакцию; - `REJECT` возвращает отказ. - 7. Проверить сценарии для `DeviceKey`, `RootKey`, `Custom`. + 7. Проверить сценарии для `ClientKey`, `RootKey`, `Custom`. - Ожидаемый результат: - сайт может подключить кошелёк через provider расширения; diff --git a/Dev_Docs/Solana/user_pda/README.md b/Dev_Docs/Solana/user_pda/README.md index 1039e38..6039f04 100644 --- a/Dev_Docs/Solana/user_pda/README.md +++ b/Dev_Docs/Solana/user_pda/README.md @@ -12,8 +12,9 @@ - логин пользователя; - неизменяемые параметры создания записи; +- публичный recovery-ключ пользователя; - корневой публичный ключ пользователя; -- ключ устройства; +- клиентский публичный ключ пользователя; - данные одного или нескольких пользовательских блокчейнов SHiNE; - серверные данные пользователя, если пользователь выступает сервером; - серверы доступа пользователя; @@ -34,9 +35,9 @@ ## 2.1. Кто оплачивает create/update PDA -- Инструкции `create_user_pda` и `update_user_pda` оплачиваются с `device_key`. +- Инструкции `create_user_pda` и `update_user_pda` оплачиваются с `client_key`. - `root_key` используется для подписи unsigned части записи через Ed25519 instruction и не является fee payer. -- Для server PDA это правило то же самое: пополнять SOL нужно на адрес `device_key`. +- Для server PDA это правило то же самое: пополнять SOL нужно на адрес `client_key`. ## 3. Общие правила кодирования @@ -85,8 +86,9 @@ UserPdaRecordV1 | block_type | Блок | Назначение | |------------|------|------------| +| `0` | `RecoveryKeyBlock` | Ключ восстановления пользователя. | | `1` | `RootKeyBlock` | Корневой ключ пользователя. | -| `2` | `DeviceKeyBlock` | Ключ устройства пользователя. | +| `2` | `ClientKeyBlock` | Клиентский ключ пользователя. | | `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. | | `30` | `ServerProfileBlock` | Серверные данные пользователя. | | `40` | `AccessServersBlock` | Серверы доступа/relay. | @@ -97,13 +99,31 @@ UserPdaRecordV1 Правила: - неизвестный `block_type` в `format_major = 1` считается ошибкой; -- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`; +- обязательные блоки: `RecoveryKeyBlock`, `RootKeyBlock`, `ClientKeyBlock`, `BlockchainRegistryBlock`; - необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`; - каждый обязательный блок должен встречаться ровно один раз; - порядок блоков в записи фиксируется для простоты проверки: - `RootKey`, `DeviceKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`. + `RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`. -## 6. RootKeyBlock +## 6. RecoveryKeyBlock + +Recovery-ключ нужен для будущих сценариев восстановления и ротации остальных ключей. В текущей версии он только публикуется в записи и не меняется через обычный `update_user_pda`. + +```text +RecoveryKeyBlock +- block_type: u8 = 0 +- block_version: u8 = 0 +- recovery_key: [u8; 32] +``` + +Правила: + +- при создании задается публичный recovery-ключ пользователя; +- при обновлении `recovery_key` должен совпадать с предыдущей записью; +- приватный `recovery.key` в PDA не хранится; +- отдельная ротация recovery-ключа будет отдельным форматом/сценарием в будущем. + +## 7. RootKeyBlock Смена `root_key` пока не проектируется и не реализуется. Блок фиксирует только стадию `0`. @@ -120,24 +140,24 @@ RootKeyBlock - при обновлении `root_key` должен совпадать с предыдущей записью; - ротация root-key будет отдельным форматом/сценарием в будущем. -## 7. DeviceKeyBlock +## 8. ClientKeyBlock -Смена `device_key` пока также не проектируется как отдельная ротация. В версии `0` хранится один ключ устройства. +Смена `client_key` пока также не проектируется как отдельная ротация. В версии `0` хранится один клиентский ключ пользователя. ```text -DeviceKeyBlock +ClientKeyBlock - block_type: u8 = 2 - block_version: u8 = 0 -- device_key: [u8; 32] +- client_key: [u8; 32] ``` Правила: -- при создании задается текущий публичный ключ устройства; -- при обновлении ключ устройства может быть обновлен только если это отдельно разрешено бизнес-логикой инструкции; -- история устройств и несколько устройств в этом формате не хранятся. +- при создании задается текущий клиентский публичный ключ пользователя; +- при обновлении `client_key` должен совпадать с предыдущей записью; +- история устройств и несколько клиентских ключей в этом формате не хранятся. -## 8. BlockchainRegistryBlock +## 9. BlockchainRegistryBlock Блок хранит данные пользовательских блокчейнов SHiNE. Сейчас используется один блокчейн, но структура сразу сделана как список. @@ -155,7 +175,7 @@ BlockchainRegistryBlock - в будущем можно увеличить количество записей без изменения смысла `BlockchainRecord`; - каждый `BlockchainRecord` описывает один пользовательский SHiNE-блокчейн. -## 9. BlockchainRecord +## 10. BlockchainRecord ```text BlockchainRecord @@ -191,7 +211,7 @@ BlockchainRecord Arweave `tx_id` - обычное поле внутри записи конкретного блокчейна. Solana-программа не проверяет, что такой Arweave transaction действительно существует и содержит корректные данные; это ответственность клиента/сервера/пользователя. -## 10. Правила обновления BlockchainRecord +## 11. Правила обновления BlockchainRecord При обновлении записи: @@ -229,7 +249,7 @@ last_block_signature = Ed25519(blockchain_public_key, message) Причина проверки подписи `LastBlockState`: `root_key` управляет Solana-записью пользователя, а `blockchain_public_key` подтверждает состояние конкретного пользовательского блокчейна. Подписывается не голый хэш, а связка логина, имени блокчейна, номера последнего блока, хэша последнего блока и занятого размера. -## 11. ServerProfileBlock +## 12. ServerProfileBlock Блок присутствует, если пользователь выступает сервером. @@ -255,7 +275,7 @@ ServerProfileBlock - `server_address` - строковый адрес сервера в соответствии с `address_format_type`; - `sync_servers` - логины SHiNE-пользователей, зарегистрированных как серверы, с которыми этот сервер синхронизирует блокчейн и личные сообщения. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы. -## 12. AccessServersBlock +## 13. AccessServersBlock Блок хранит серверы доступа/relay для пользователя. @@ -274,7 +294,7 @@ AccessServersBlock - `access_servers` - логины пользователей системы, используемых как серверы доступа/relay. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы; - точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE. -## 13. SessionsBlock +## 14. SessionsBlock Блок хранит опубликованные пользовательские сессии. На текущем этапе регистрация пользователя не добавляет туда записи автоматически, поэтому стандартный create/update продолжает работать с пустым списком. @@ -309,6 +329,7 @@ SessionRecord | Значение | Смысл | |----------|-------| | `1` | Обычная пользовательская сессия. | +| `50` | Кошелёк пользователя. | | `100` | Homeserver пользователя. | Правила: @@ -320,7 +341,7 @@ SessionRecord - внутри одного блока должны быть уникальны и `session_name`, и `session_pub_key`; - на текущем этапе UI и регистрация не обязаны добавлять туда записи автоматически. -## 14. TrustedStateBlock +## 15. TrustedStateBlock Пока trusted-логика не реализована полностью, поэтому блок хранит только счетчик. @@ -333,7 +354,7 @@ TrustedStateBlock Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат. -## 15. Подпись user_pda +## 16. Подпись user_pda Подписывается не вся PDA целиком, а unsigned-часть записи: @@ -354,7 +375,7 @@ Solana-программа проверяет подпись через встр Смену формата подписи сейчас не трогаем. -## 16. Регистрация пользователя +## 17. Регистрация пользователя При регистрации: @@ -372,12 +393,12 @@ Solana-программа проверяет подпись через встр - если покупается дополнительный лимит, пользователь платит комиссию за этот лимит; - вся unsigned-часть записи подписана `root_key`. -## 17. Обновление пользователя +## 18. Обновление пользователя При обновлении: - PDA должна существовать; -- `login`, `created_at_ms`, `root_key` не меняются; +- `login`, `created_at_ms`, `recovery_key`, `root_key`, `client_key` не меняются; - `record_number = previous_record_number + 1`; - `prev_record_hash` равен хэшу unsigned-части предыдущей записи; - `updated_at_ms` обновляется; @@ -387,7 +408,7 @@ Solana-программа проверяет подпись через встр - при увеличении оплаченного лимита пользователь доплачивает комиссию; - Arweave `tx_id` может быть пустым или обновленным, но его содержимое Solana не валидирует. -## 18. Отличия от старого линейного формата +## 19. Отличия от старого линейного формата Старый формат после `login` хранил поля линейно: @@ -395,8 +416,8 @@ Solana-программа проверяет подпись через встр - `root_key`; - `blockchain_key_status`; - `blockchain_key`; -- `device_key_status`; -- `device_key`; +- `client_key_status`; +- `client_key`; - `chain_number`; - `balance`; - серверные поля; @@ -407,17 +428,54 @@ Solana-программа проверяет подпись через встр Новый целевой формат сохраняет первые 9 фиксированных полей как заголовок, но дальше переходит на типизированные блоки: +- recovery-ключ становится отдельным обязательным блоком; - ключи становятся отдельными блоками; - данные блокчейна становятся расширенным блоком со своим публичным ключом, лимитом, занятым размером, вершиной цепочки и Arweave `tx_id`; - серверные данные и access-серверы отделяются от данных блокчейна; - расширение формата делается добавлением новых версий блоков или новых `block_type`, а не вставкой полей в середину линейной записи. -## 18. Что пока не входит в формат +## 20. Деривация ключей из master secret + +Сама Solana-программа не вычисляет ключи из секрета и не хранит приватные ключи. Но текущая согласованная клиентская схема деривации для публичной версии формата фиксируется здесь как reference для UI/ESP32/внешних клиентов. + +Базовая формула: + +```text +seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || suffix_utf8) +``` + +Где: + +- `master_secret32` — 32-байтовый master secret пользователя; +- `suffix_utf8` — строка назначения ключа. + +Согласованные suffix: + +```text +"recovery.key" +"root.key" +"blockchain.key" +"client.key" +``` + +Соответствие: + +```text +recovery.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "recovery.key") +root.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "root.key") +blockchain.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "blockchain.key") +client.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "client.key") +``` + +Далее каждая строка `seed` интерпретируется off-chain как `seed32` для отдельной пары Ed25519. + +## 21. Что пока не входит в формат Пока не проектируем: +- ротацию `recovery_key`; - ротацию `root_key`; -- сложную ротацию `device_key`; +- сложную ротацию `client_key`; - ротацию `blockchain_public_key`; - проверку содержимого Arweave transaction; - хранение полной истории пользовательского блокчейна внутри Solana; diff --git a/Dev_Docs/Solana_Architecture/details/accounts_and_money_flow.md b/Dev_Docs/Solana_Architecture/details/accounts_and_money_flow.md index 0aafaeb..4745ed1 100644 --- a/Dev_Docs/Solana_Architecture/details/accounts_and_money_flow.md +++ b/Dev_Docs/Solana_Architecture/details/accounts_and_money_flow.md @@ -19,7 +19,7 @@ 5. `DAO_TREASURY_WALLET` / `dao_wallet` — казна DAO. 6. `manager_wallet` — кошелек менеджера, которому DAO выдает лимиты на создание тикетов. 7. `user root_key` — корневой ключ пользователя для подписи пользовательской записи. -8. `user device_key` — ключ устройства пользователя. +8. `user client_key` — ключ устройства пользователя. 9. `server_key` — ключ сервера пользователя, если пользователь является сервером. Текущие адреса из `programs/common/src/deploy_config.rs`: diff --git a/Dev_Docs/Solana_Architecture/details/shine_users.md b/Dev_Docs/Solana_Architecture/details/shine_users.md index d8e7004..e268bc2 100644 --- a/Dev_Docs/Solana_Architecture/details/shine_users.md +++ b/Dev_Docs/Solana_Architecture/details/shine_users.md @@ -62,7 +62,7 @@ `UserMutableFields`: -- `device_key: Pubkey` +- `client_key: Pubkey` - `blockchain_public_key: Pubkey` - `blockchain_name: String` - `used_bytes: u64` diff --git a/Dev_Docs/Solana_Architecture/schemes/architecture.svg b/Dev_Docs/Solana_Architecture/schemes/architecture.svg index a731eb3..9ef76d3 100644 --- a/Dev_Docs/Solana_Architecture/schemes/architecture.svg +++ b/Dev_Docs/Solana_Architecture/schemes/architecture.svg @@ -32,7 +32,7 @@ Пользователь - signer, root_key, device_key + signer, root_key, client_key Покупатель тикета diff --git a/Dev_Docs/audit/Solana-audit-3-by-Claude-12июня2026.md b/Dev_Docs/audit/Solana-audit-3-by-Claude-12июня2026.md index e5a93f6..e456584 100644 --- a/Dev_Docs/audit/Solana-audit-3-by-Claude-12июня2026.md +++ b/Dev_Docs/audit/Solana-audit-3-by-Claude-12июня2026.md @@ -43,8 +43,8 @@ bump, подмена аккаунта оракула) не найдено. Вс |---|---|---|---|---|---|---| | `init_users_economy_config` | ✓ | `owner == system` + `data_is_empty` (анти-reinit) | деривация + сверка | ✓ | — | значения из `settings`, не из ввода | | `update_users_economy_config` | ✓ + `signer == DAO_AUTHORITY` | `owner == program_id` | деривация + сверка | — | — | `lamports_per_limit_step > 0` | -| `create_user_pda` | ✓ + `signer == device_key` | user_pda `owner == system` + empty; econ_config `owner == program_id` | user_pda, econ_config, inflow_vault, login_guard — все сверены | ✓ | ed25519 (record sig idx −2, last_block idx −1) | `inflow_vault` сверен с PDA `shine_payments`; login_guard сверен дважды | -| `update_user_pda` | ✓ + `signer == device_key` | user_pda `owner == program_id`; econ_config `owner == program_id` | деривация + сверка | ✓ | ed25519 + `version == old+1` + `prev_hash == hash(old)` | immutable-поля сверены с прежней записью | +| `create_user_pda` | ✓ + `signer == client_key` | user_pda `owner == system` + empty; econ_config `owner == program_id` | user_pda, econ_config, inflow_vault, login_guard — все сверены | ✓ | ed25519 (record sig idx −2, last_block idx −1) | `inflow_vault` сверен с PDA `shine_payments`; login_guard сверен дважды | +| `update_user_pda` | ✓ + `signer == client_key` | user_pda `owner == program_id`; econ_config `owner == program_id` | деривация + сверка | ✓ | ed25519 + `version == old+1` + `prev_hash == hash(old)` | immutable-поля сверены с прежней записью | ### shine_payments @@ -111,7 +111,7 @@ commit-reveal; для текущей модели — приемлемый ри - **Подмена PDA** невозможна нигде: всюду пара «деривация `find_program_address` + сверка полного адреса». Пользовательский bump не принимается, `create_program_address` с внешним bump не используется — bump-seed атаки исключены. - **Проверка владельца** при каждом чтении PDA: `read_state` и `validate_singleton_state_pda` (`shine_payments`) требуют `owner == id()`; `validate_users_economy_config_pda` и проверка `user_pda.owner == program_id` (`shine_users`) — перед десериализацией данных. - **Создаваемые PDA**: проверка `is_uninitialized` / `owner == system && data_is_empty` исключает повторную инициализацию и перезапись чужого аккаунта. -- **signer / authority**: все handler начинают с обязательного `is_signer`; привилегированные операции дополнительно сверяют ключ с авторитетом (`config.dao_wallet`, `DAO_AUTHORITY`, `allowance.manager_wallet`, `ticket.recipient_wallet`, `device_key`). +- **signer / authority**: все handler начинают с обязательного `is_signer`; привилегированные операции дополнительно сверяют ключ с авторитетом (`config.dao_wallet`, `DAO_AUTHORITY`, `allowance.manager_wallet`, `ticket.recipient_wallet`, `client_key`). - **system-program** сверяется с `system_program::ID` там, где идёт создание аккаунта/перевод; **sysvar инструкций** сверяется с `sysvar::instructions::id()` перед ed25519-интроспекцией. - **Аккаунт оракула**: пин адреса `PYTH_SOL_USD_ACCOUNT` + `owner == pyth_receiver` + `feed_id` + возраст (120 с) + доверительный интервал (10%). - **Ed25519 в `shine_users`**: относительные индексы −1/−2, `num_signatures == 1`, все три `ix_index == u16::MAX` (offset-данные внутри самой ed25519-инструкции), сверка `program_id == ed25519_program` и pubkey/signature/message по хэшу — указать на чужую инструкцию нельзя. diff --git a/Dev_Docs/Инициализация_Solana_регистрации/README.md b/Dev_Docs/Инициализация_Solana_регистрации/README.md index 98b91d1..f62ea20 100644 --- a/Dev_Docs/Инициализация_Solana_регистрации/README.md +++ b/Dev_Docs/Инициализация_Solana_регистрации/README.md @@ -84,15 +84,15 @@ anchor deploy -p shine_users ## Кто оплачивает create/update user_pda -- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `deviceKey`. +- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `clientKey`. - В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя/сервера. - `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию. -- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `deviceKey`. +- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `clientKey`. ## Важно - `init_users_economy_config` выполняется один раз на программу. Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение). -- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: в UI/server UI транзакцию оплачивает именно `deviceKey`, а содержимое записи подписывает `rootKey`. +- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: в UI/server UI транзакцию оплачивает именно `clientKey`, а содержимое записи подписывает `rootKey`. - `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина. Несовпадение адреса приведёт к ошибке регистрации. diff --git a/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md b/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md index 2022536..5333464 100644 --- a/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md +++ b/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md @@ -4,7 +4,7 @@ ## 1. Текущие режимы -### 1. Создание новой сессии через `deviceKey` +### 1. Создание новой сессии через `clientKey` Поток: @@ -12,8 +12,8 @@ Смысл: -- новое устройство уже владеет приватным `deviceKey`; -- сервер проверяет подпись `deviceKey`; +- новое устройство уже владеет приватным `clientKey`; +- сервер проверяет подпись `clientKey`; - создаётся обычная активная сессия пользователя; - этот поток остаётся без изменений. @@ -67,7 +67,7 @@ ## 4. Чего сервер в этой версии не делает -- не передаёт приватный `deviceKey`; +- не передаёт приватный `clientKey`; - не расшифровывает `encryptedPayload`; - не проверяет криптографию содержимого payload; - не делает клиентский UI; diff --git a/Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md b/Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md index 81d50f5..25e48b4 100644 --- a/Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md +++ b/Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md @@ -3,11 +3,11 @@ Сокращение: **SAWD-v1**. ## Назначение -Из 32-байтного `deviceKey32` пользователя получить один и тот же нативный Arweave RSA-4096 JWK wallet и один и тот же Arweave address. +Из 32-байтного `clientKey32` пользователя получить один и тот же нативный Arweave RSA-4096 JWK wallet и один и тот же Arweave address. ## Вход -- `deviceKey32`: ровно 32 байта. -- Если исходный `device.key` хранится как Ed25519 PKCS8 base64, нужно извлечь последние 32 байта из PKCS8. +- `clientKey32`: ровно 32 байта. +- Если исходный `client.key` хранится как Ed25519 PKCS8 base64, нужно извлечь последние 32 байта из PKCS8. - Если используется Solana keypair JSON на 64 байта, используются только `bytes[0..31]`. ## Выход @@ -46,8 +46,8 @@ - `SMALL_PRIME_LIMIT = 10000` ## Алгоритм -1. Проверить `deviceKey32.length == 32`. -2. `masterSeed32 = HMAC-SHA256(key = UTF8(MASTER_LABEL), message = deviceKey32)`. +1. Проверить `clientKey32.length == 32`. +2. `masterSeed32 = HMAC-SHA256(key = UTF8(MASTER_LABEL), message = clientKey32)`. 3. Реализовать `deriveBytes(label, length)`: - `output = empty` - `counter = 0` diff --git a/Dev_Docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md b/Dev_Docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md index 137ac30..1b0c431 100644 --- a/Dev_Docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md +++ b/Dev_Docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md @@ -16,7 +16,7 @@ На устройстве в UI пользователь выбирает текущий активный кошелёк: -- `dev.key` +- `client.key` - `root.key` - `custom` @@ -115,7 +115,7 @@ ESP32 возвращает: - `requestId` — должен совпадать с `requestId` исходного запроса. - `ok` — признак успешного результата. - `wallet.type` — тип активного кошелька: - - `dev.key` + - `client.key` - `root.key` - `custom` - `wallet.publicKeyBase58` — публичный ключ активного кошелька в `Base58`. @@ -156,11 +156,11 @@ ESP32 возвращает: Расширение уже знает публичные ключи пользователя из Solana PDA. Поэтому оно может дополнительно проверить ответ ESP32: -- если `wallet.type = dev.key`, то `publicKeyBase58` должен совпасть с `deviceKey`, прочитанным из PDA; +- если `wallet.type = client.key`, то `publicKeyBase58` должен совпасть с `clientKey`, прочитанным из PDA; - если `wallet.type = root.key`, то `publicKeyBase58` должен совпасть с `rootKey`, прочитанным из PDA; - если `wallet.type = custom`, такой проверки по PDA в первой версии нет. -При несовпадении для `dev.key` или `root.key` расширение должно показать пользователю предупреждение, что возвращённый ключ не совпал с ожидаемым ключом из PDA. +При несовпадении для `client.key` или `root.key` расширение должно показать пользователю предупреждение, что возвращённый ключ не совпал с ожидаемым ключом из PDA. ## 8. Ожидаемое поведение UI расширения diff --git a/ESP32/AGENTS.md b/ESP32/AGENTS.md index 5afda45..44c8aea 100644 --- a/ESP32/AGENTS.md +++ b/ESP32/AGENTS.md @@ -9,3 +9,9 @@ ## Синхронизация со спецификацией - При изменении экранов, кнопок, переходов, статусов или текстов обязательно обновлять соответствующую спецификацию в `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/`. + +## Сборка ESP32 + +- Основной способ проверки и прошивки скетчей для `ESP32-S3-Touch-AMOLED-2.16` - `main-device/burn.sh`. +- Не собирать эти скетчи напрямую через `arduino-cli compile` без `burn.sh`, потому что скрипт добавляет нужные локальные библиотеки и конфиги из `official-demo/examples/Arduino-v3.3.5/libraries`. +- Если сборка падает по `lv_conf.h` или `TouchDrvCSTXXX.hpp`, сначала проверять именно `burn.sh` и его `--library` пути, а не считать, что файл пропал из репозитория. diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/argon2_sd_test/argon2_sd_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/argon2_sd_test/argon2_sd_test.ino index cdeab1f..50dd4e2 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/argon2_sd_test/argon2_sd_test.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/argon2_sd_test/argon2_sd_test.ino @@ -11,7 +11,7 @@ * legacy(empty password): * secret = SHA256(base64(SHA256(password)) + "master.secret") * keyPair_i = Ed25519(SHA256(base64(secret) + "|" + suffix_i)) - * suffixes = ["root.key", "bch.key", "dev.key"] + * suffixes = ["root.key", "blockchain.key", "client.key"] * * Плата: Waveshare ESP32-S3-Touch-AMOLED-2.16 * SD : SDMMC 1-bit CLK=GPIO2, CMD=GPIO1, D0=GPIO3 @@ -116,8 +116,8 @@ static bool gKbNums = false; // ═══════════════════════════════════════════════════════════ struct KeyPair { uint8_t pub[32]; uint8_t priv[32]; }; static KeyPair gKeys[3]; -static const char * KEY_SUFFIXES[3] = {"root.key", "bch.key", "dev.key"}; -static const char * KEY_LABELS[3] = {"root.key", "bch.key", "dev.key"}; +static const char * KEY_SUFFIXES[3] = {"root.key", "blockchain.key", "client.key"}; +static const char * KEY_LABELS[3] = {"root.key", "blockchain.key", "client.key"}; static uint32_t gElapsedSec = 0; // Base58 представления (43-44 символа для 32 байт + \0) diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index 2a204d1..9117066 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -81,7 +81,7 @@ static const char *kPaymentsInflowSeed = "shine_payments_inflow_vault"; static const char *kLastBlockPrefix = "SHiNE_LAST_BLOCK"; static const char *kProgramDerivedAddressMarker = "ProgramDerivedAddress"; static const uint8_t kBlockTypeRootKey = 1; -static const uint8_t kBlockTypeDeviceKey = 2; +static const uint8_t kBlockTypeClientKey = 2; static const uint8_t kBlockTypeBlockchainRegistry = 3; static const uint8_t kBlockTypeServerProfile = 30; static const uint8_t kBlockTypeAccessServers = 40; @@ -230,7 +230,7 @@ struct ShinePdaUserState { uint8_t sessionsMode = 1; uint8_t trustedCount = 0; uint8_t rootKey32[32] = {}; - uint8_t deviceKey32[32] = {}; + uint8_t clientKey32[32] = {}; uint8_t blockchainKey32[32] = {}; String blockchainName; uint64_t paidLimitBytes = 0; @@ -542,7 +542,7 @@ static std::vector buildUnsignedCreateRecord( const String &blockchainName, const String &serverAddress, const uint8_t rootPub[32], - const uint8_t devicePub[32], + const uint8_t clientPub[32], const uint8_t blockchainPub[32], const uint8_t lastBlockSignature[64], uint64_t paidLimitBytes, @@ -552,7 +552,7 @@ static std::vector buildCreateInstructionData( const String &blockchainName, const String &serverAddress, const uint8_t rootPub[32], - const uint8_t devicePub[32], + const uint8_t clientPub[32], const uint8_t blockchainPub[32], const uint8_t lastBlockSignature[64], const uint8_t rootSignature[64], @@ -566,7 +566,7 @@ static std::vector buildUpdateInstructionData(const ShinePdaUserState & const uint8_t rootSignature64[64]); static std::vector buildLegacyMessage( const uint8_t recentBlockhash[32], - const uint8_t devicePub[32], + const uint8_t clientPub[32], const uint8_t userPda[32], const uint8_t inflowVault[32], const uint8_t economyConfig[32], @@ -575,7 +575,7 @@ static std::vector buildLegacyMessage( const std::vector &createData); static std::vector buildUpdateLegacyMessage( const uint8_t recentBlockhash[32], - const uint8_t devicePub[32], + const uint8_t clientPub[32], const uint8_t userPda[32], const uint8_t inflowVault[32], const uint8_t economyConfig[32], @@ -1234,8 +1234,8 @@ static void refreshDerivedKeys() { return; } deriveKeyPairFromSecretSuffix(gSecretBytes, "root.key", gRootPubB58, gRootPrivB58); - deriveKeyPairFromSecretSuffix(gSecretBytes, "bch.key", gBlockchainPubB58, gBlockchainPrivB58); - deriveKeyPairFromSecretSuffix(gSecretBytes, "dev.key", gDevicePubB58, gDevicePrivB58); + deriveKeyPairFromSecretSuffix(gSecretBytes, "blockchain.key", gBlockchainPubB58, gBlockchainPrivB58); + deriveKeyPairFromSecretSuffix(gSecretBytes, "client.key", gDevicePubB58, gDevicePrivB58); deriveKeyPairFromSecretSuffix(gSecretBytes, homeserverKeySuffix(), gHomeserverPubB58, gHomeserverPrivB58); String customName = gCustomWalletName; customName.trim(); @@ -1255,7 +1255,7 @@ static String selectedWalletDisplayName() { } case WALLET_SELECTION_DEVICE: default: - return "DeviceKey"; + return "ClientKey"; } } @@ -1267,7 +1267,7 @@ static String selectedWalletTypeCode() { return "custom"; case WALLET_SELECTION_DEVICE: default: - return "dev.key"; + return "client.key"; } } @@ -1282,7 +1282,7 @@ static String selectedWalletDerivationSuffix() { } case WALLET_SELECTION_DEVICE: default: - return "dev.key"; + return "client.key"; } } @@ -1759,9 +1759,9 @@ static std::vector serializeUnsignedRecordState(const ShinePdaUserState out.push_back(0); pushFixed(out, state.rootKey32, 32); - out.push_back(kBlockTypeDeviceKey); + out.push_back(kBlockTypeClientKey); out.push_back(0); - pushFixed(out, state.deviceKey32, 32); + pushFixed(out, state.clientKey32, 32); out.push_back(kBlockTypeBlockchainRegistry); out.push_back(0); @@ -1837,7 +1837,7 @@ static std::vector buildUpdateInstructionData(const ShinePdaUserState & pushU32LE(out, nextVersion); pushFixed(out, prevHash32, 32); pushU64LE(out, 0); - pushFixed(out, state.deviceKey32, 32); + pushFixed(out, state.clientKey32, 32); pushFixed(out, state.blockchainKey32, 32); pushStrU8(out, state.blockchainName); pushU64LE(out, state.usedBytes); @@ -1882,7 +1882,7 @@ static std::vector buildUnsignedCreateRecord( const String &blockchainName, const String &serverAddress, const uint8_t rootPub[32], - const uint8_t devicePub[32], + const uint8_t clientPub[32], const uint8_t blockchainPub[32], const uint8_t lastBlockSignature[64], uint64_t paidLimitBytes, @@ -1905,9 +1905,9 @@ static std::vector buildUnsignedCreateRecord( out.push_back(0); pushFixed(out, rootPub, 32); - out.push_back(kBlockTypeDeviceKey); + out.push_back(kBlockTypeClientKey); out.push_back(0); - pushFixed(out, devicePub, 32); + pushFixed(out, clientPub, 32); out.push_back(kBlockTypeBlockchainRegistry); out.push_back(0); @@ -1954,7 +1954,7 @@ static std::vector buildCreateInstructionData( const String &blockchainName, const String &serverAddress, const uint8_t rootPub[32], - const uint8_t devicePub[32], + const uint8_t clientPub[32], const uint8_t blockchainPub[32], const uint8_t lastBlockSignature[64], const uint8_t rootSignature[64], @@ -1966,7 +1966,7 @@ static std::vector buildCreateInstructionData( pushFixed(out, rootPub, 32); pushU64LE(out, createdAtMs); pushU64LE(out, 0); - pushFixed(out, devicePub, 32); + pushFixed(out, clientPub, 32); pushFixed(out, blockchainPub, 32); pushStrU8(out, blockchainName); pushU64LE(out, 0); @@ -2215,7 +2215,7 @@ static bool simulateTransactionForError(const String &txBase64, String &messageO static std::vector buildLegacyMessage( const uint8_t recentBlockhash[32], - const uint8_t devicePub[32], + const uint8_t clientPub[32], const uint8_t userPda[32], const uint8_t inflowVault[32], const uint8_t economyConfig[32], @@ -2234,7 +2234,7 @@ static std::vector buildLegacyMessage( base58ToFixed32(kShineLoginGuardProgramId, loginGuardProgram); std::vector> accountKeys; - accountKeys.emplace_back(devicePub, devicePub + 32); + accountKeys.emplace_back(clientPub, clientPub + 32); accountKeys.emplace_back(userPda, userPda + 32); accountKeys.emplace_back(inflowVault, inflowVault + 32); accountKeys.emplace_back(systemProgram, systemProgram + 32); @@ -2282,7 +2282,7 @@ static std::vector buildLegacyMessage( static std::vector buildUpdateLegacyMessage( const uint8_t recentBlockhash[32], - const uint8_t devicePub[32], + const uint8_t clientPub[32], const uint8_t userPda[32], const uint8_t inflowVault[32], const uint8_t economyConfig[32], @@ -2299,7 +2299,7 @@ static std::vector buildUpdateLegacyMessage( base58ToFixed32(kSysvarInstructionsId, sysvarInstructions); std::vector> accountKeys; - accountKeys.emplace_back(devicePub, devicePub + 32); + accountKeys.emplace_back(clientPub, clientPub + 32); accountKeys.emplace_back(userPda, userPda + 32); accountKeys.emplace_back(inflowVault, inflowVault + 32); accountKeys.emplace_back(systemProgram, systemProgram + 32); @@ -2708,17 +2708,17 @@ static bool registerHomeserverOnSolana(String &messageOut) { uint8_t blockchainSeed[32] = {}; uint8_t blockchainPub[32] = {}; uint8_t blockchainSec[64] = {}; - uint8_t deviceSeed[32] = {}; - uint8_t devicePub[32] = {}; + uint8_t clientSeed[32] = {}; + uint8_t clientPub[32] = {}; uint8_t deviceSec[64] = {}; if (!deriveSeedKeypairFromBase58(gRootPrivB58, rootSeed, rootPub, rootSec) || !deriveSeedKeypairFromBase58(gBlockchainPrivB58, blockchainSeed, blockchainPub, blockchainSec) || - !deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)) { + !deriveSeedKeypairFromBase58(gDevicePrivB58, clientSeed, clientPub, deviceSec)) { return failWithDiag("Failed to restore keys"); } diagDetails += String("root_pub=") + bytesToBase58(rootPub, 32) + "\n"; diagDetails += String("blockchain_pub=") + bytesToBase58(blockchainPub, 32) + "\n"; - diagDetails += String("device_pub=") + bytesToBase58(devicePub, 32) + "\n"; + diagDetails += String("device_pub=") + bytesToBase58(clientPub, 32) + "\n"; String blockchainName = cleanLogin + "-001"; diagDetails += String("blockchain_name=") + blockchainName + "\n"; @@ -2736,7 +2736,7 @@ static bool registerHomeserverOnSolana(String &messageOut) { diagDetails += String("created_at_ms=") + String((unsigned long long)createdAtMs) + "\n"; std::vector unsignedRecord = buildUnsignedCreateRecord( cleanLogin, blockchainName, gShineServerUrl, - rootPub, devicePub, blockchainPub, + rootPub, clientPub, blockchainPub, lastBlockSignature, startBonusLimit, createdAtMs); uint8_t unsignedHash[32]; uint8_t rootSignature[64]; @@ -2750,7 +2750,7 @@ static bool registerHomeserverOnSolana(String &messageOut) { std::vector createData = buildCreateInstructionData( cleanLogin, blockchainName, gShineServerUrl, - rootPub, devicePub, blockchainPub, + rootPub, clientPub, blockchainPub, lastBlockSignature, rootSignature, createdAtMs); std::vector edRootData = buildEd25519InstructionData(rootSignature, rootPub, unsignedHash); std::vector edBchData = buildEd25519InstructionData(lastBlockSignature, blockchainPub, lastBlockHash); @@ -2765,7 +2765,7 @@ static bool registerHomeserverOnSolana(String &messageOut) { std::vector message = buildLegacyMessage( recentBlockhash, - devicePub, + clientPub, userPda, inflowVault, economyConfig, @@ -3012,17 +3012,17 @@ static bool updateHomeserverSessionOnSolana(bool requireExisting, String &messag uint8_t rootSeed[32] = {}; uint8_t rootPub[32] = {}; uint8_t rootSec[64] = {}; - uint8_t deviceSeed[32] = {}; - uint8_t devicePub[32] = {}; + uint8_t clientSeed[32] = {}; + uint8_t clientPub[32] = {}; uint8_t deviceSec[64] = {}; uint8_t homeserverPub[32] = {}; if (!deriveSeedKeypairFromBase58(gRootPrivB58, rootSeed, rootPub, rootSec) - || !deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec) + || !deriveSeedKeypairFromBase58(gDevicePrivB58, clientSeed, clientPub, deviceSec) || !base58ToFixed32(gHomeserverPubB58, homeserverPub)) { return failWithDiag("Failed to restore local keys"); } - if (memcmp(devicePub, currentState.deviceKey32, 32) != 0) { - return failWithDiag("Device key does not match PDA"); + if (memcmp(clientPub, currentState.clientKey32, 32) != 0) { + return failWithDiag("Client key does not match PDA"); } uint8_t userPda[32] = {}; @@ -3048,7 +3048,7 @@ static bool updateHomeserverSessionOnSolana(bool requireExisting, String &messag ShinePdaUserState nextState = currentState; memcpy(nextState.rootKey32, rootPub, 32); - memcpy(nextState.deviceKey32, devicePub, 32); + memcpy(nextState.clientKey32, clientPub, 32); nextState.updatedAtMs = shineNowMs(); nextState.recordNumber = currentState.recordNumber + 1; if (nextState.sessionsMode == 0) { @@ -3121,7 +3121,7 @@ static bool updateHomeserverSessionOnSolana(bool requireExisting, String &messag std::vector message = buildUpdateLegacyMessage( recentBlockhash, - devicePub, + clientPub, userPda, inflowVault, economyConfig, @@ -3289,9 +3289,9 @@ static bool parseShineUserPdaBytes(const std::vector &bytes, ShinePdaUs } continue; } - if (blockType == kBlockTypeDeviceKey) { - if (!readBytes(outState.deviceKey32, 32)) { - errorOut = "Bad device key block"; + if (blockType == kBlockTypeClientKey) { + if (!readBytes(outState.clientKey32, 32)) { + errorOut = "Bad client key block"; return false; } continue; @@ -3520,10 +3520,10 @@ static void refreshAccountPdaStatus() { } uint8_t rootPub[32] = {}; - uint8_t devicePub[32] = {}; + uint8_t clientPub[32] = {}; uint8_t blockchainPub[32] = {}; if (!base58ToFixed32(gRootPubB58, rootPub) - || !base58ToFixed32(gDevicePubB58, devicePub) + || !base58ToFixed32(gDevicePubB58, clientPub) || !base58ToFixed32(gBlockchainPubB58, blockchainPub)) { gAccountPdaStatus = ACCOUNT_PDA_MISMATCH; gAccountPdaStatusMessage = "local keys invalid"; @@ -3537,8 +3537,8 @@ static void refreshAccountPdaStatus() { mismatch = "root key mismatch"; } else if (memcmp(blockchainPub, pdaState.blockchainKey32, 32) != 0) { mismatch = "blockchain key mismatch"; - } else if (memcmp(devicePub, pdaState.deviceKey32, 32) != 0) { - mismatch = "device key mismatch"; + } else if (memcmp(clientPub, pdaState.clientKey32, 32) != 0) { + mismatch = "client key mismatch"; } else if (gHomeserverValue.isEmpty()) { mismatch = "homeserver not set"; } else { @@ -3963,20 +3963,20 @@ static bool buildPkcs8FromSeed32(const uint8_t seed32[32], String &pkcs8B64Out) static bool buildPairingSecretsPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut) { payloadJsonOut = ""; errorOut = ""; - uint8_t deviceSeed[32] = {}; - uint8_t devicePub[32] = {}; + uint8_t clientSeed[32] = {}; + uint8_t clientPub[32] = {}; uint8_t deviceSec[64] = {}; - if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)) { - errorOut = "Failed to derive device key"; + if (!deriveSeedKeypairFromBase58(gDevicePrivB58, clientSeed, clientPub, deviceSec)) { + errorOut = "Failed to derive client key"; return false; } String devicePkcs8; - if (!buildPkcs8FromSeed32(deviceSeed, devicePkcs8)) { - errorOut = "Failed to encode device key"; + if (!buildPkcs8FromSeed32(clientSeed, devicePkcs8)) { + errorOut = "Failed to encode client key"; return false; } payloadJsonOut = String("{\"v\":1,\"type\":\"shine-esp-pairing-transfer\",\"login\":\"") + jsonEscape(gLoginValue) - + "\",\"mode\":\"device-only\",\"keys\":{\"deviceKey\":\"" + jsonEscape(devicePkcs8) + + "\",\"mode\":\"device-only\",\"keys\":{\"clientKey\":\"" + jsonEscape(devicePkcs8) + "\",\"blockchainKey\":\"\",\"rootKey\":\"\"},\"payloadType\":1,\"createdAtMs\":" + String((unsigned long long)shineNowMs()) + "}"; return true; @@ -3985,11 +3985,11 @@ static bool buildPairingSecretsPayload(const PairingRequestUiItem &item, String static bool createDelegatedWalletSessionPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut) { payloadJsonOut = ""; errorOut = ""; - uint8_t deviceSeed[32] = {}; - uint8_t devicePub[32] = {}; + uint8_t clientSeed[32] = {}; + uint8_t clientPub[32] = {}; uint8_t deviceSec[64] = {}; - if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)) { - errorOut = "Failed to derive device key"; + if (!deriveSeedKeypairFromBase58(gDevicePrivB58, clientSeed, clientPub, deviceSec)) { + errorOut = "Failed to derive client key"; return false; } @@ -4040,7 +4040,7 @@ static bool createDelegatedWalletSessionPayload(const PairingRequestUiItem &item + "\",\"storagePwd\":\"" + jsonEscape(storagePwd) + "\",\"timeMs\":" + String((unsigned long long)timeMs) + ",\"authNonce\":\"" + jsonEscape(authNonce) - + "\",\"deviceKey\":\"" + jsonEscape(bytesToBase64String(devicePub, 32)) + + "\",\"clientKey\":\"" + jsonEscape(bytesToBase64String(clientPub, 32)) + "\",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64)) + "\",\"sessionType\":50" + ",\"clientPlatform\":\"" + jsonEscape(clientPlatform) @@ -4262,13 +4262,13 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { diagDetails += String("server_time_offset_ms=") + String((long long)gShineServerTimeOffsetMs) + "\n"; } - uint8_t deviceSeed[32] = {}; - uint8_t devicePub[32] = {}; + uint8_t clientSeed[32] = {}; + uint8_t clientPub[32] = {}; uint8_t deviceSec[64] = {}; uint8_t subSeed[32] = {}; uint8_t subPub[32] = {}; uint8_t subSec[64] = {}; - if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec) + if (!deriveSeedKeypairFromBase58(gDevicePrivB58, clientSeed, clientPub, deviceSec) || !deriveSeedKeypairFromBase58(gHomeserverPrivB58, subSeed, subPub, subSec)) { return failWithDiag("local key derive failed"); } @@ -4368,7 +4368,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) { + "\",\"storagePwd\":\"" + jsonEscape(gShineStoragePwd) + "\",\"timeMs\":" + String((unsigned long long)timeMs) + ",\"authNonce\":\"" + jsonEscape(authNonce) - + "\",\"deviceKey\":\"" + jsonEscape(bytesToBase64String(devicePub, 32)) + + "\",\"clientKey\":\"" + jsonEscape(bytesToBase64String(clientPub, 32)) + "\",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64)) + "\",\"sessionType\":" + String((unsigned int)kSessionTypeHomeserver) + ",\"clientPlatform\":\"" + jsonEscape(kSessionClientPlatformEsp32) @@ -4510,7 +4510,7 @@ static void loadPrefs() { gShineServerUrl = gPrefs.getString("shine_server", "https://shineup.me"); gLoginValue = gPrefs.getString("login", ""); gHomeserverValue = gPrefs.getString("homeserver", "homeserver1"); - String walletTypeStored = gPrefs.getString("wallet_type", "dev.key"); + String walletTypeStored = gPrefs.getString("wallet_type", "client.key"); if (walletTypeStored == "root.key") { gSelectedWalletType = WALLET_SELECTION_ROOT; } else if (walletTypeStored == "custom") { @@ -5786,7 +5786,7 @@ static void drawWalletSelectScreen() { makeTitle("SELECT WALLET", 22, &lv_font_montserrat_24); String currentLine = String("Current: ") + selectedWalletDisplayName(); makeBody(currentLine.c_str(), 88, 420); - String deviceLabel = String(gSelectedWalletType == WALLET_SELECTION_DEVICE ? "✓ DeviceKey" : "DeviceKey"); + String deviceLabel = String(gSelectedWalletType == WALLET_SELECTION_DEVICE ? "✓ ClientKey" : "ClientKey"); String rootLabel = String(gSelectedWalletType == WALLET_SELECTION_ROOT ? "✓ RootKey" : "RootKey"); String customBase = gCustomWalletName; customBase.trim(); @@ -6081,7 +6081,7 @@ static void drawPairingRequestDetailScreen() { String question = String("Connect session ") + pairingSessionNameLabel(item) + "?"; String explain = item.requesterSessionType == 50 ? "Wallet session. No keys will be transferred." - : "Client session. Only device key will be transferred. No additional keys will be sent."; + : "Client session. Only client key will be transferred. No additional keys will be sent."; String sessionNameText = String("Session: ") + pairingSessionNameLabel(item); String sessionKindText = String("Kind: ") + pairingSessionKindLabel(item.requesterSessionType); @@ -6301,8 +6301,8 @@ static void drawSecretShowScreen() { addKeyBlock("Root key priv (base58)", "sha256(base64(secret)|root.key)", gRootPrivB58); addKeyBlock("Blockchain key (base58)", "pub from sha256(base64(secret)|bch.key)", gBlockchainPubB58); addKeyBlock("Blockchain key priv (base58)", "sha256(base64(secret)|bch.key)", gBlockchainPrivB58); - addKeyBlock("Device key (base58)", "pub from sha256(base64(secret)|dev.key)", gDevicePubB58); - addKeyBlock("Device key priv (base58)", "sha256(base64(secret)|dev.key)", gDevicePrivB58); + addKeyBlock("Client key (base58)", "pub from sha256(base64(secret)|client.key)", gDevicePubB58); + addKeyBlock("Client key priv (base58)", "sha256(base64(secret)|client.key)", gDevicePrivB58); addKeyBlock("Homeserver key (base58)", String("pub from sha256(base64(secret)|") + homeserverKeySuffix() + ")", gHomeserverPubB58); addKeyBlock("Homeserver key priv (base58)", String("sha256(base64(secret)|") + homeserverKeySuffix() + ")", gHomeserverPrivB58); } else { diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/shine_homeserver_ui.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/shine_homeserver_ui.ino index e071a95..e644f06 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/shine_homeserver_ui.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/shine_homeserver_ui.ino @@ -228,7 +228,7 @@ struct DerivedKeyState { uint8_t rootSk[64]; uint8_t blockchainPub[32]; uint8_t blockchainSk[64]; - uint8_t devicePub[32]; + uint8_t clientPub[32]; uint8_t deviceSk[64]; }; @@ -246,7 +246,7 @@ static const char *kPaymentsInflowSeed = "shine_payments_inflow_vault"; static const char *kProgramDerivedAddressMarker = "ProgramDerivedAddress"; static const char *kLastBlockPrefix = "SHiNE_LAST_BLOCK"; static const uint8_t kBlockTypeRootKey = 1; -static const uint8_t kBlockTypeDeviceKey = 2; +static const uint8_t kBlockTypeClientKey = 2; static const uint8_t kBlockTypeBlockchainRegistry = 3; static const uint8_t kBlockTypeServerProfile = 30; static const uint8_t kBlockTypeAccessServers = 40; @@ -786,8 +786,8 @@ static bool deriveKeysFromMasterSecret(const uint8_t masterSecret[32]) { if (secretB64.length() == 0) { return false; } - const char *suffixes[3] = {"root.key", "bch.key", "dev.key"}; - uint8_t *pubs[3] = {gDerivedKeys.rootPub, gDerivedKeys.blockchainPub, gDerivedKeys.devicePub}; + const char *suffixes[3] = {"root.key", "blockchain.key", "client.key"}; + uint8_t *pubs[3] = {gDerivedKeys.rootPub, gDerivedKeys.blockchainPub, gDerivedKeys.clientPub}; uint8_t *sks[3] = {gDerivedKeys.rootSk, gDerivedKeys.blockchainSk, gDerivedKeys.deviceSk}; for (int i = 0; i < 3; i++) { String material = secretB64 + "|" + suffixes[i]; @@ -822,7 +822,7 @@ static bool restoreDerivedKeysFromSecret() { return false; } gData.secretReady = true; - gData.walletAddress = bytesToBase58(gDerivedKeys.devicePub, 32); + gData.walletAddress = bytesToBase58(gDerivedKeys.clientPub, 32); return true; } @@ -835,7 +835,7 @@ static bool deriveFreshSecretAndWallet() { return false; } gData.secret = bytesToBase58(secret, sizeof(secret)); - gData.walletAddress = bytesToBase58(gDerivedKeys.devicePub, 32); + gData.walletAddress = bytesToBase58(gDerivedKeys.clientPub, 32); gData.secretReady = true; return true; } @@ -889,7 +889,7 @@ static std::vector buildUnsignedCreateRecord( const String &blockchainName, const String &serverAddress, const uint8_t rootPub[32], - const uint8_t devicePub[32], + const uint8_t clientPub[32], const uint8_t blockchainPub[32], const uint8_t lastBlockSignature[64], uint64_t createdAtMs) { @@ -911,9 +911,9 @@ static std::vector buildUnsignedCreateRecord( out.push_back(0); pushFixed(out, rootPub, 32); - out.push_back(kBlockTypeDeviceKey); + out.push_back(kBlockTypeClientKey); out.push_back(0); - pushFixed(out, devicePub, 32); + pushFixed(out, clientPub, 32); out.push_back(kBlockTypeBlockchainRegistry); out.push_back(0); @@ -960,7 +960,7 @@ static std::vector buildCreateInstructionData( const String &blockchainName, const String &serverAddress, const uint8_t rootPub[32], - const uint8_t devicePub[32], + const uint8_t clientPub[32], const uint8_t blockchainPub[32], const uint8_t lastBlockSignature[64], const uint8_t rootSignature[64], @@ -972,7 +972,7 @@ static std::vector buildCreateInstructionData( pushFixed(out, rootPub, 32); pushU64LE(out, createdAtMs); pushU64LE(out, 0); - pushFixed(out, devicePub, 32); + pushFixed(out, clientPub, 32); pushFixed(out, blockchainPub, 32); pushStrU8(out, blockchainName); pushU64LE(out, 0); @@ -1079,7 +1079,7 @@ static bool pdaAlreadyExists(const String &login, String &pdaAddress, String &me static std::vector buildLegacyMessage( const uint8_t recentBlockhash[32], - const uint8_t devicePub[32], + const uint8_t clientPub[32], const uint8_t userPda[32], const uint8_t inflowVault[32], const uint8_t economyConfig[32], @@ -1098,7 +1098,7 @@ static std::vector buildLegacyMessage( base58ToFixed32(kShineLoginGuardProgramId, loginGuardProgram); std::vector> accountKeys; - accountKeys.emplace_back(devicePub, devicePub + 32); + accountKeys.emplace_back(clientPub, clientPub + 32); accountKeys.emplace_back(userPda, userPda + 32); accountKeys.emplace_back(inflowVault, inflowVault + 32); accountKeys.emplace_back(systemProgram, systemProgram + 32); @@ -1244,7 +1244,7 @@ static bool registerHomeserverOnSolana(String &messageOut) { uint64_t createdAtMs = (uint64_t)millis() + 1704067200000ULL; std::vector unsignedRecord = buildUnsignedCreateRecord( gData.login, blockchainName, gData.wsUrl, - gDerivedKeys.rootPub, gDerivedKeys.devicePub, gDerivedKeys.blockchainPub, + gDerivedKeys.rootPub, gDerivedKeys.clientPub, gDerivedKeys.blockchainPub, lastBlockSignature, createdAtMs); uint8_t unsignedHash[32]; uint8_t rootSignature[64]; @@ -1256,7 +1256,7 @@ static bool registerHomeserverOnSolana(String &messageOut) { std::vector createData = buildCreateInstructionData( gData.login, blockchainName, gData.wsUrl, - gDerivedKeys.rootPub, gDerivedKeys.devicePub, gDerivedKeys.blockchainPub, + gDerivedKeys.rootPub, gDerivedKeys.clientPub, gDerivedKeys.blockchainPub, lastBlockSignature, rootSignature, createdAtMs); std::vector edRootData = buildEd25519InstructionData(rootSignature, gDerivedKeys.rootPub, unsignedHash); std::vector edBchData = buildEd25519InstructionData(lastBlockSignature, gDerivedKeys.blockchainPub, lastBlockHash); @@ -1269,7 +1269,7 @@ static bool registerHomeserverOnSolana(String &messageOut) { std::vector message = buildLegacyMessage( recentBlockhash, - gDerivedKeys.devicePub, + gDerivedKeys.clientPub, userPda, inflowVault, economyConfig, @@ -2107,7 +2107,7 @@ static void drawConfirmScreen() { String text = "Выполнить действие?"; if (gConfirmTarget == CONFIRM_REGISTER) { title = "Регистрация"; - text = "Отправить create_user_pda в Solana через device key этого устройства?"; + text = "Отправить create_user_pda в Solana через client key этого устройства?"; } else if (gConfirmTarget == CONFIRM_CLEAR_ACCOUNT) { title = "Очистка"; text = "Удалить секрет, кошелёк и статус регистрации?"; diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md index 9ca9e84..24ebcc3 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md @@ -70,11 +70,11 @@ Фоновая логика: - пока открыт `HOME`, экран сам обновляется примерно раз в секунду; - при наличии `login + secret + homeserver` и Wi-Fi устройство читает Solana `user_pda` напрямую через RPC; -- сравниваются `root key`, `blockchain key`, `device key` и `homeserver` session-запись типа `100`; +- сравниваются `root key`, `blockchain key`, `client key` и `homeserver` session-запись типа `100`; - для строки `SHiNE:` устройство держит отдельную WebSocket-сессию с сервером SHiNE: - авторизация через `AuthChallenge/CreateAuthSession` или `SessionChallenge/SessionLogin`; - session key = публичный `homeserver key`; - - подтверждение создания сессии подписывается `device key`; + - подтверждение создания сессии подписывается `client key`; - heartbeat выполняется `Ping` раз в минуту. ## SETTINGS_MENU @@ -214,8 +214,8 @@ - `Root key priv (base58)`; - `Blockchain key (base58)`; - `Blockchain key priv (base58)`; -- `Device key (base58)`; -- `Device key priv (base58)`; +- `Client key (base58)`; +- `Client key priv (base58)`; - `Homeserver key (base58)`; - `Homeserver key priv (base58)`; - для каждого поля показывается формула derivation; diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md index eee3b39..72d67bb 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_spec.md @@ -178,7 +178,7 @@ - вместо старых двух кнопок `баланс` и `QR` показывается одна широкая кнопка; - текст кнопки: `Wallet: `; - доступные имена: - - `DeviceKey` + - `ClientKey` - `RootKey` - либо сохранённое имя `custom`-кошелька; - после старта устройства баланс активного выбранного кошелька пытается загрузиться автоматически, если уже есть секрет и `Wi-Fi`; @@ -429,7 +429,7 @@ - верхняя кнопка открывает экран выбора кошелька `WALLET_SELECT`; - `Показать баланс кошелька` читает реальный баланс именно активного выбранного кошелька из `Solana RPC`; - `Показать QR-код кошелька` открывает экран `WALLET_QR`; -- этот экран не меняет логику Solana-регистрации пользователя: on-chain регистрация и проверки `PDA` по-прежнему завязаны на `device key`. +- этот экран не меняет логику Solana-регистрации пользователя: on-chain регистрация и проверки `PDA` по-прежнему завязаны на `client key`. ## Экран WALLET_SELECT @@ -437,14 +437,14 @@ - строку `Current: `; - три кнопки выбора: - - `DeviceKey` + - `ClientKey` - `RootKey` - `Custom` или `Custom: <имя>`; - у текущего выбора видна галочка. Поведение: -- `DeviceKey` активирует кошелёк, выведенный из suffix `dev.key`; +- `ClientKey` активирует кошелёк, выведенный из suffix `client.key`; - `RootKey` активирует кошелёк, выведенный из suffix `root.key`; - `Custom` использует derivation: `sha256(base64(secret32) + "|wallet." + customName)`; @@ -533,7 +533,7 @@ - строку `Session: `; - строку `Kind: Client session` или `Kind: Wallet session`; - пояснение: - - для client session: `Only device key will be transferred. No additional keys will be sent.` + - для client session: `Only client key will be transferred. No additional keys will be sent.` - для wallet session: `No keys will be transferred.` Кнопки: @@ -544,7 +544,7 @@ Поведение: - `YES` подтверждает заявку: - - для client session устройство передаёт только `device key`; + - для client session устройство передаёт только `client key`; - для wallet session устройство выпускает отдельную `wallet-session` без передачи ключей; - `NO` отклоняет заявку; - после любого решения устройство возвращается в список `REQUESTS` и обновляет его; diff --git a/SHiNE-browser-plugin-wallet/README.md b/SHiNE-browser-plugin-wallet/README.md index eed308b..209dc4a 100644 --- a/SHiNE-browser-plugin-wallet/README.md +++ b/SHiNE-browser-plugin-wallet/README.md @@ -7,7 +7,7 @@ Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login. - создать `wallet-session` через `StartTrustedDeviceLogin`; - показать код подключения; - дождаться подтверждения на доверенном устройстве; -- принять `session-only` payload без передачи `deviceKey/rootKey/blockchainKey`; +- принять `session-only` payload без передачи `clientKey/rootKey/blockchainKey`; - сохранить `sessionPriv/sessionKey/sessionId` в локальном хранилище plugin; - восстанавливать session через `SessionChallenge -> SessionLogin`; - держать wallet-state в `background service worker`, а side panel использовать как UI. diff --git a/SHiNE-browser-plugin-wallet/background.js b/SHiNE-browser-plugin-wallet/background.js index 18e6d16..dd51774 100644 --- a/SHiNE-browser-plugin-wallet/background.js +++ b/SHiNE-browser-plugin-wallet/background.js @@ -563,11 +563,13 @@ function verifyWalletAgainstPda(wallet) { const type = String(wallet?.type || '').trim(); const pub = String(wallet?.publicKeyBase58 || '').trim(); const rootKey = String(state.walletProfile?.publicKeys?.rootKeyBase58 || '').trim(); - const deviceKey = String(state.walletProfile?.publicKeys?.deviceKeyBase58 || '').trim(); - if (type === 'dev.key') { + const clientKey = String( + state.walletProfile?.publicKeys?.clientKeyBase58 || '', + ).trim(); + if (type === 'client.key') { return { - verified: !!deviceKey && deviceKey === pub, - verificationText: deviceKey === pub ? 'Совпадает с deviceKey из PDA.' : 'Не совпадает с deviceKey из PDA.', + verified: !!clientKey && clientKey === pub, + verificationText: clientKey === pub ? 'Совпадает с clientKey из PDA.' : 'Не совпадает с clientKey из PDA.', }; } if (type === 'root.key') { diff --git a/SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js b/SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js index e6fda2e..3d36165 100644 --- a/SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js +++ b/SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js @@ -69,8 +69,9 @@ function parseServerFieldsFromUserPda(dataBytes) { let isServer = false; let serverAddress = ''; let accessServers = []; + let recoveryKey32 = null; let rootKey32 = null; - let deviceKey32 = null; + let clientKey32 = null; let blockchainKey32 = null; let blockchainName = ''; let homeserverSessions = []; @@ -79,10 +80,11 @@ function parseServerFieldsFromUserPda(dataBytes) { const blockType = readU8(bytes, cursorRef); cursorRef.value += 1; // block_version - if (blockType === 1 || blockType === 2) { + if (blockType === 0 || blockType === 1 || blockType === 2) { const key32 = readBytes(bytes, cursorRef, 32); + if (blockType === 0) recoveryKey32 = key32; if (blockType === 1) rootKey32 = key32; - if (blockType === 2) deviceKey32 = key32; + if (blockType === 2) clientKey32 = key32; continue; } if (blockType === 3) { @@ -150,8 +152,9 @@ function parseServerFieldsFromUserPda(dataBytes) { serverAddress: normalizeHostLike(serverAddress), accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean), publicKeys: { + recoveryKeyBase58: recoveryKey32 ? new PublicKey(recoveryKey32).toBase58() : '', rootKeyBase58: rootKey32 ? new PublicKey(rootKey32).toBase58() : '', - deviceKeyBase58: deviceKey32 ? new PublicKey(deviceKey32).toBase58() : '', + clientKeyBase58: clientKey32 ? new PublicKey(clientKey32).toBase58() : '', blockchainKeyBase58: blockchainKey32 ? new PublicKey(blockchainKey32).toBase58() : '', blockchainName, }, diff --git a/SHiNE-browser-plugin-wallet/popup.html b/SHiNE-browser-plugin-wallet/popup.html index 2e37ae4..773a2ae 100644 --- a/SHiNE-browser-plugin-wallet/popup.html +++ b/SHiNE-browser-plugin-wallet/popup.html @@ -52,7 +52,7 @@
Логин
Session
Типwallet
-
deviceKey
+
clientKey
diff --git a/SHiNE-browser-plugin-wallet/popup.js b/SHiNE-browser-plugin-wallet/popup.js index 5f55508..655f74c 100644 --- a/SHiNE-browser-plugin-wallet/popup.js +++ b/SHiNE-browser-plugin-wallet/popup.js @@ -19,7 +19,7 @@ const els = { sessionLogin: document.querySelector('#session-login'), sessionId: document.querySelector('#session-id'), sessionType: document.querySelector('#session-type'), - deviceKeyShort: document.querySelector('#device-key-short'), + clientKeyShort: document.querySelector('#client-key-short'), resumeBtn: document.querySelector('#resume-btn'), refreshDevicesBtn: document.querySelector('#refresh-devices-btn'), disconnectBtn: document.querySelector('#disconnect-btn'), @@ -134,7 +134,7 @@ function applyState(nextState) { els.sessionLogin.textContent = session.login || '—'; els.sessionId.textContent = session.sessionId || '—'; els.sessionType.textContent = String(session.sessionType || 50) === '50' ? 'wallet' : String(session.sessionType || '—'); - els.deviceKeyShort.textContent = shortKey(walletProfile?.publicKeys?.deviceKeyBase58 || ''); + els.clientKeyShort.textContent = shortKey(walletProfile?.publicKeys?.clientKeyBase58 || ''); } const homeservers = Array.isArray(walletProfile?.homeserverSessions) ? walletProfile.homeserverSessions : []; diff --git a/SHiNE-server/shine-server-blockchain/all_files.txt b/SHiNE-server/shine-server-blockchain/all_files.txt deleted file mode 100644 index b4b46b2..0000000 --- a/SHiNE-server/shine-server-blockchain/all_files.txt +++ /dev/null @@ -1,2884 +0,0 @@ -package blockchain; - -import blockchain.body.BodyRecord; - -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.time.Instant; -import java.util.Arrays; -import java.util.Objects; - -/** - * BchBlockEntry — универсальный блок формата SHiNE (Frame v0). - * - * ========================================================================= - * FRAME v0 — ФИКСИРОВАННЫЙ ФОРМАТ БЛОКА (ДОКУМЕНТ ПРОТОКОЛА) - * ========================================================================= - * - * Все числа BigEndian. - * - * PREIMAGE (входит в blockSize, подписывается): - * [2] frameCode (uint16) код/версия рамки: - * - 0x0000 = Frame v0 (текущий) - * [32] prevHash32 (bytes) SHA-256(preimage) предыдущего блока (цепочка) - * [4] blockSize (int32) размер preimage (в байтах), ВКЛЮЧАЯ frameCode, - * НО БЕЗ sigMarker и БЕЗ signature64 - * [4] blockNumber (int32) глобальный номер блока (>=0) - * [8] timestamp (int64) unix seconds - * [2] type (uint16) тип сообщения - * [2] subType (uint16) подтип сообщения - * [2] version (uint16) версия формата сообщения - * [N] bodyBytes (bytes) тело сообщения (БЕЗ type/subType/version) - * - * TAIL (НЕ входит в blockSize, НЕ подписывается в Frame v0): - * [2] sigMarker (uint16) маркер подписи: - * - 0x0100 (256) = далее подпись Ed25519 64 байта - * [64] signature64 (bytes) Ed25519 signature над hash32 - * - * hash32 НЕ хранится в блоке. - * hash32 вычисляется при парсинге: - * preimage = первые blockSize байт - * hash32 = SHA-256(preimage) - * - * Правила MVP-парсера (Frame v0): - * - frameCode должен быть строго 0x0000, иначе REJECT. - * - sigMarker должен быть строго 0x0100, иначе REJECT. - * - подпись обязана присутствовать всегда (sigMarker+signature64). - * - НИКАКИХ fallback-веток “если маркер другой, то подписи нет/другой хвост”. - * - * Важно по безопасности: - * - sigMarker в v0 не входит в подписываемые байты → его можно подменить, - * поэтому единственная безопасная логика: "если не 0x0100 — reject". - * ========================================================================= - */ -public final class BchBlockEntry { - - public static final int SIGNATURE_LEN = 64; - public static final int HASH_LEN = 32; - - public static final int FRAME_CODE_LEN = 2; - public static final int SIG_MARKER_LEN = 2; - - /** Frame v0 */ - public static final int FRAME_CODE_V0 = 0x0000; - - /** sigMarker: 256 = 0x0100 */ - public static final int SIG_MARKER_ED25519 = 0x0100; - - /** - * Максимальный допустимый размер блока (fullBytes = preimage + sigMarker + signature), - * чтобы не уложить сервер по памяти/диску. - */ - public static final int MAX_BLOCK_FULL_BYTES = 4 * 1024 * 1024; - - /** - * Насколько блок может “обгонять” текущее время (защита от кривых часов/вбросов). - * Если timestamp больше now + 60 сек — блок считаем неверным. - */ - public static final long MAX_FUTURE_SECONDS = 60; - - /** - * Размер фиксированной части PREIMAGE (без bodyBytes). - * - * PREIMAGE header: - * frameCode(2) + prevHash32(32) + blockSize(4) + blockNumber(4) + timestamp(8) - * + type(2) + subType(2) + version(2) - */ - public static final int PREIMAGE_HEADER_SIZE = - 2 // frameCode - + 32 // prevHash32 - + 4 // blockSize - + 4 // blockNumber - + 8 // timestamp - + 2 // type - + 2 // subType - + 2; // version - - /** Минимальный полный размер блока (без bodyBytes). */ - public static final int MIN_FULL_BYTES = - PREIMAGE_HEADER_SIZE + SIG_MARKER_LEN + SIGNATURE_LEN; - - // --- HEADER (PREIMAGE) --- - public final int frameCode; // uint16 (v0=0) - public final byte[] prevHash32; // 32 - public final int blockSize; // preimage size (включая frameCode) - public final int blockNumber; // >=0 - public final long timestamp; - public final short type; - public final short subType; - public final short version; - - // --- BODY (PREIMAGE) --- - public final byte[] bodyBytes; - - /** Распарсенное тело (создаётся сразу при парсинге блока). */ - public final BodyRecord body; - - // --- TAIL --- - public final int sigMarker; // uint16 (v0: 0x0100) - private final byte[] signature64; // 64 - - // --- derived --- - private final byte[] hash32; // 32, computed - private final byte[] preimage; // blockSize bytes - private final byte[] fullBytes; // preimage + sigMarker + signature - - /* ===================================================================== */ - /* ====================== Конструктор из байт ========================== */ - /* ===================================================================== */ - - public BchBlockEntry(byte[] fullBytes) { - Objects.requireNonNull(fullBytes, "fullBytes == null"); - - if (fullBytes.length < MIN_FULL_BYTES) { - throw new IllegalArgumentException("Block too short: " + fullBytes.length + " < " + MIN_FULL_BYTES); - } - if (fullBytes.length > MAX_BLOCK_FULL_BYTES) { - throw new IllegalArgumentException("Block too large: " + fullBytes.length + " > " + MAX_BLOCK_FULL_BYTES); - } - - ByteBuffer bb = ByteBuffer.wrap(fullBytes).order(ByteOrder.BIG_ENDIAN); - - // [2] frameCode - this.frameCode = Short.toUnsignedInt(bb.getShort()); - if (this.frameCode != FRAME_CODE_V0) { - throw new IllegalArgumentException(String.format( - "Bad frameCode: 0x%04X (expected 0x%04X)", this.frameCode, FRAME_CODE_V0 - )); - } - - // [32] prevHash32 - this.prevHash32 = new byte[32]; - bb.get(this.prevHash32); - - // [4] blockSize - this.blockSize = bb.getInt(); - if (blockSize < PREIMAGE_HEADER_SIZE) { - throw new IllegalArgumentException("blockSize too small: " + blockSize + " < " + PREIMAGE_HEADER_SIZE); - } - - // fullLen must match exactly: blockSize + sigMarker(2) + signature(64) - int expectedFullLen = blockSize + SIG_MARKER_LEN + SIGNATURE_LEN; - if (expectedFullLen != fullBytes.length) { - throw new IllegalArgumentException("blockSize mismatch: blockSize=" + blockSize - + " expectedFullLen=" + expectedFullLen - + " fullLen=" + fullBytes.length); - } - if (expectedFullLen > MAX_BLOCK_FULL_BYTES) { - throw new IllegalArgumentException("Block too large by blockSize: " + expectedFullLen + " > " + MAX_BLOCK_FULL_BYTES); - } - - // [4] blockNumber - this.blockNumber = bb.getInt(); - if (this.blockNumber < 0) { - throw new IllegalArgumentException("blockNumber < 0: " + this.blockNumber); - } - - // [8] timestamp - this.timestamp = bb.getLong(); - - // запрет “в будущее” больше чем на 1 минуту - long now = Instant.now().getEpochSecond(); - if (this.timestamp > now + MAX_FUTURE_SECONDS) { - throw new IllegalArgumentException("timestamp is too far in future: ts=" + this.timestamp - + " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS); - } - - // [2][2][2] type/subType/version - this.type = bb.getShort(); - this.subType = bb.getShort(); - this.version = bb.getShort(); - - // [N] bodyBytes - int bodyLen = blockSize - PREIMAGE_HEADER_SIZE; - if (bodyLen < 0) { - throw new IllegalArgumentException("Invalid body length: " + bodyLen); - } - this.bodyBytes = new byte[bodyLen]; - bb.get(this.bodyBytes); - - // TAIL: [2] sigMarker - this.sigMarker = Short.toUnsignedInt(bb.getShort()); - if (this.sigMarker != SIG_MARKER_ED25519) { - throw new IllegalArgumentException(String.format( - "Bad sigMarker: 0x%04X (expected 0x%04X)", this.sigMarker, SIG_MARKER_ED25519 - )); - } - - // TAIL: [64] signature64 - this.signature64 = new byte[SIGNATURE_LEN]; - bb.get(this.signature64); - - // preimage = первые blockSize байт (включая frameCode) - this.preimage = Arrays.copyOfRange(fullBytes, 0, blockSize); - - // hash32 = sha256(preimage) - this.hash32 = BchCryptoVerifier.sha256(preimage); - - // parse body по header.type/subType/version + ОБЯЗАТЕЛЬНЫЙ check() - this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes); - - this.fullBytes = Arrays.copyOf(fullBytes, fullBytes.length); - - if (bb.remaining() != 0) { - throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); - } - } - - /* ===================================================================== */ - /* ====================== Конструктор сборки ============================ */ - /* ===================================================================== */ - - public BchBlockEntry(byte[] prevHash32, - int blockNumber, - long timestamp, - short type, - short subType, - short version, - byte[] bodyBytes, - byte[] signature64) { - - Objects.requireNonNull(prevHash32, "prevHash32 == null"); - Objects.requireNonNull(bodyBytes, "bodyBytes == null"); - Objects.requireNonNull(signature64, "signature64 == null"); - - if (prevHash32.length != 32) throw new IllegalArgumentException("prevHash32 != 32"); - if (signature64.length != SIGNATURE_LEN) throw new IllegalArgumentException("signature64 != 64"); - - if (blockNumber < 0) { - throw new IllegalArgumentException("blockNumber < 0: " + blockNumber); - } - - // запрет “в будущее” больше чем на 1 минуту - long now = Instant.now().getEpochSecond(); - if (timestamp > now + MAX_FUTURE_SECONDS) { - throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp - + " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS); - } - - this.frameCode = FRAME_CODE_V0; - this.prevHash32 = Arrays.copyOf(prevHash32, 32); - this.blockNumber = blockNumber; - this.timestamp = timestamp; - this.type = type; - this.subType = subType; - this.version = version; - this.bodyBytes = Arrays.copyOf(bodyBytes, bodyBytes.length); - - // blockSize = размер preimage (включая frameCode) - this.blockSize = PREIMAGE_HEADER_SIZE + this.bodyBytes.length; - - int fullLen = this.blockSize + SIG_MARKER_LEN + SIGNATURE_LEN; - if (fullLen > MAX_BLOCK_FULL_BYTES) { - throw new IllegalArgumentException("Block too large: " + fullLen + " > " + MAX_BLOCK_FULL_BYTES); - } - - // parse body по header + ОБЯЗАТЕЛЬНЫЙ check() - this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes); - - // tail marker фиксирован - this.sigMarker = SIG_MARKER_ED25519; - this.signature64 = Arrays.copyOf(signature64, SIGNATURE_LEN); - - // build preimage - ByteBuffer pre = ByteBuffer.allocate(blockSize).order(ByteOrder.BIG_ENDIAN); - pre.putShort((short) (FRAME_CODE_V0 & 0xFFFF)); - pre.put(this.prevHash32); - pre.putInt(this.blockSize); - pre.putInt(this.blockNumber); - pre.putLong(this.timestamp); - pre.putShort(this.type); - pre.putShort(this.subType); - pre.putShort(this.version); - pre.put(this.bodyBytes); - - this.preimage = pre.array(); - this.hash32 = BchCryptoVerifier.sha256(preimage); - - // build fullBytes: preimage + sigMarker + signature64 - ByteBuffer full = ByteBuffer.allocate(fullLen).order(ByteOrder.BIG_ENDIAN); - full.put(this.preimage); - full.putShort((short) (SIG_MARKER_ED25519 & 0xFFFF)); - full.put(this.signature64); - this.fullBytes = full.array(); - } - - /* ===================================================================== */ - /* ============================ Getters ================================= */ - /* ===================================================================== */ - - public byte[] getPreimageBytes() { - return Arrays.copyOf(preimage, preimage.length); - } - - /** Возвращает подпись Ed25519 (64 байта). */ - public byte[] getSignature64() { - return Arrays.copyOf(signature64, SIGNATURE_LEN); - } - - /** Возвращает hash32 = SHA-256(preimage). */ - public byte[] getHash32() { - return Arrays.copyOf(hash32, HASH_LEN); - } - - /** Возвращает полный блок: preimage + sigMarker + signature. */ - public byte[] toBytes() { - return Arrays.copyOf(fullBytes, fullBytes.length); - } - - @Override - public String toString() { - String timeIso; - try { - timeIso = Instant.ofEpochSecond(timestamp).toString(); - } catch (Exception e) { - timeIso = "некорректныйTimestamp"; - } - - return "BchBlockEntry{" - + "FRAME{frameCode=0x" + hex4(frameCode) - + "}, HDR{" - + "blockSize=" + blockSize - + ", blockNumber=" + blockNumber - + ", timestamp=" + timestamp + " (" + timeIso + ")" - + ", type=" + (type & 0xFFFF) - + ", subType=" + (subType & 0xFFFF) - + ", version=" + (version & 0xFFFF) - + ", prevHash32(hex)=" + toHex(prevHash32) - + "}" - + ", BODY{len=" + (bodyBytes == null ? -1 : bodyBytes.length) + "}" - + ", TAIL{sigMarker=0x" + hex4(sigMarker) + ", signature64(hex)=" + toHex(signature64) + "}" - + ", DERIVED{hash32(hex)=" + toHex(hash32) + "}" - + "}"; - } - - private static String hex4(int v) { - String s = Integer.toHexString(v & 0xFFFF); - while (s.length() < 4) s = "0" + s; - return s; - } - - private static String toHex(byte[] bytes) { - if (bytes == null) return "null"; - char[] HEX = "0123456789abcdef".toCharArray(); - char[] out = new char[bytes.length * 2]; - for (int i = 0; i < bytes.length; i++) { - int vv = bytes[i] & 0xFF; - out[i * 2] = HEX[vv >>> 4]; - out[i * 2 + 1] = HEX[vv & 0x0F]; - } - return new String(out); - } -} -package blockchain; - -import utils.crypto.Ed25519Util; - -import java.security.MessageDigest; -import java.util.Objects; - -/** - * Верификатор SHiNE (Frame v0): - * - * preimage = первые blockSize байт блока (ВКЛЮЧАЯ frameCode=0x0000), - * = всё до TAIL (sigMarker+signature). - * - * hash32 = SHA-256(preimage) - * verify = Ed25519.verify(hash32, signature64, pubKey32) - */ -public final class BchCryptoVerifier { - - private BchCryptoVerifier() {} - - public static byte[] sha256(byte[] data) { - Objects.requireNonNull(data, "data == null"); - try { - MessageDigest d = MessageDigest.getInstance("SHA-256"); - return d.digest(data); - } catch (Exception e) { - throw new IllegalStateException("SHA-256 unavailable", e); - } - } - - public static boolean verifyBlock(BchBlockEntry block, byte[] publicKey32) { - Objects.requireNonNull(block, "block == null"); - Objects.requireNonNull(publicKey32, "publicKey32 == null"); - - if (publicKey32.length != 32) throw new IllegalArgumentException("publicKey32 != 32"); - - byte[] hash32 = block.getHash32(); - byte[] sig64 = block.getSignature64(); - - return Ed25519Util.verify(hash32, sig64, publicKey32); - } -} -package blockchain.body; - -/** - * BodyHasLine — для типов, которые имеют линейные поля в body. - * - * Line-prefix (BigEndian) в НАЧАЛЕ bodyBytes: - * [4] lineCode код линии (root-идентификатор): - * - 0 для дефолтной линии/канала "0" (root = HEADER, blockNumber=0) - * - для канала "X": blockNumber root-блока канала (CREATE_CHANNEL) - * - * [4] prevLineBlockGlobalNumber глобальный номер предыдущего блока в этой линии - * [32] prevLineBlockHash32 hash32 предыдущего блока в этой линии - * - * [4] lineSeq порядковый номер сообщения внутри линии (1..N) - * - * Важно: - * - Проверка связности линии (prevLineBlockGlobalNumber ↔ prevLineBlockHash32) и корректности lineSeq - * выполняется на сервере/в БД при вставке (а не в body.check()). - */ -public interface BodyHasLine { - - int lineCode(); - - int prevLineBlockGlobalNumber(); - - byte[] prevLineBlockHash32(); - - int lineSeq(); -} -package blockchain.body; - -import utils.blockchain.BlockchainNameUtil; - -/** - * BodyHasTarget — дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля). - * - * Новое правило: - * - toLogin НЕ храним в байтах блока. - * - toLogin всегда вычисляется из toBchName по стандарту login+"-NNN". - * - * Все методы могут возвращать null. - */ -public interface BodyHasTarget { - - /** login цели (nullable). Вычисляется из toBchName(). */ - default String toLogin() { - String bch = toBchName(); - if (bch == null) return null; - return BlockchainNameUtil.loginFromBlockchainName(bch); - } - - /** blockchainName цели (nullable). */ - String toBchName(); - - /** globalNumber цели (nullable). */ - Integer toBlockGlobalNumber(); - - /** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */ - byte[] toBlockHashBytes(); -} -package blockchain.body; - -/** - * BodyRecord — общий контракт для всех типов body (тела блока). - * - * ВАЖНО (новый формат): - * - type/subType/version НЕ лежат в bodyBytes. - * - type/subType/version читаются из заголовка блока (BchBlockEntry). - * - * Поэтому из интерфейса УБРАНЫ: - * - type() - * - subType() - * - version() - * - expectedLineIndex() - */ -public interface BodyRecord { - - /** Проверить корректность содержимого и вернуть этот объект (или кинуть исключение). */ - BodyRecord check(); - - /** - * Сериализовать тело записи в байты (ровно то, что кладётся в block.bodyBytes). - * Важно: НЕ включает type/subType/version. - */ - byte[] toBytes(); -} -package blockchain.body; - -import blockchain.MsgSubType; -import utils.blockchain.BlockchainNameUtil; - -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.Objects; - -/** - * ConnectionBody — type=3, ver=1 (в заголовке блока). - * - * subType (в заголовке блока) как MsgSubType: - * FRIEND=10, UNFRIEND=11 - * CONTACT=20, UNCONTACT=21 - * FOLLOW=30, UNFOLLOW=31 - * - * bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ): - * [4] lineCode - * [4] prevLineNumber - * [32] prevLineHash32 - * [4] thisLineNumber - * - * [1] toBlockchainNameLen (uint8) - * [N] toBlockchainName UTF-8 - * [4] toBlockGlobalNumber (int32) - * [32] toBlockHash32 (raw 32 bytes) - * - * toLogin вычисляется автоматически из toBlockchainName: - * toLogin = BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) - */ - -/** - * ========================================================================= - * ПРАВИЛО TARGET/ROOT ДЛЯ КАНАЛОВ И СВЯЗЕЙ (важно для подписок/друзей/контактов) - * ========================================================================= - * - * Термины: - * - ROOT линии/канала = блок, который "начинает" линию: - * * для канала "0" root = HEADER (blockNumber=0) - * * для канала "X" root = CREATE_CHANNEL (blockNumber этого блока) - * - * 1) СВЯЗИ МЕЖДУ ПОЛЬЗОВАТЕЛЯМИ (CONNECTION_*): - * FRIEND / CONTACT -> цель ВСЕГДА HEADER пользователя: - * toBlockNumber = 0 - * toBlockHash32 = hash32(HEADER цели) - * - * 2) ПОДПИСКИ НА КОНТЕНТ (FOLLOW/SUBSCRIBE): - * FOLLOW пользователя (в целом) -> цель = ROOT дефолтного канала "0" (то есть HEADER): - * toBlockNumber = 0 - * toBlockHash32 = hash32(HEADER цели) - * - * FOLLOW/подписка на конкретный канал пользователя -> - * цель = ROOT этого канала: - * - канал "0": toBlockNumber=0, toBlockHash32=hash32(HEADER) - * - канал "X": toBlockNumber=blockNumber(CREATE_CHANNEL), - * toBlockHash32=hash32(CREATE_CHANNEL) - * - * 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД): - * - CONNECTION_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено). - * - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала: - * разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL). - * - * Зачем так: - * - связи и подписки всегда стабильны и не ломаются при новых постах, - * - один понятный инвариант: "подписка всегда указывает на root линии". - * ========================================================================= - */ - -public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine { - - public static final short TYPE = 3; - public static final short VER = 1; - - public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); - - public final short subType; // из header - public final short version; // из header - - // line - public final int lineCode; - public final int prevLineNumber; - public final byte[] prevLineHash32; - public final int thisLineNumber; - - // payload - public final String toBlockchainName; - public final int toBlockGlobalNumber; - public final byte[] toBlockHash32; - - public ConnectionBody(short subType, short version, byte[] bodyBytes) { - Objects.requireNonNull(bodyBytes, "bodyBytes == null"); - - this.subType = subType; - this.version = version; - - if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { - throw new IllegalArgumentException("ConnectionBody version must be 1, got=" + (this.version & 0xFFFF)); - } - if (!isValidSubType(this.subType)) { - throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF)); - } - - // минимум: - // lineCode(4) + line(4+32+4) + toBchLen[1]+toBch[1] + global[4] + hash[32] - if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1 + 4 + 32) { - throw new IllegalArgumentException("ConnectionBody too short"); - } - - ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); - - this.lineCode = bb.getInt(); - - this.prevLineNumber = bb.getInt(); - - this.prevLineHash32 = new byte[32]; - bb.get(this.prevLineHash32); - - this.thisLineNumber = bb.getInt(); - - int bchLen = Byte.toUnsignedInt(bb.get()); - if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0"); - if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short"); - - byte[] bchBytes = new byte[bchLen]; - bb.get(bchBytes); - this.toBlockchainName = new String(bchBytes, StandardCharsets.UTF_8); - - this.toBlockGlobalNumber = bb.getInt(); - - this.toBlockHash32 = new byte[32]; - bb.get(this.toBlockHash32); - - if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); - } - - public ConnectionBody(int lineCode, - int prevLineNumber, - byte[] prevLineHash32, - int thisLineNumber, - short subType, - String toBlockchainName, - int toBlockGlobalNumber, - byte[] toBlockHash32) { - - Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); - Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); - - if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); - if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF)); - - if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); - // Железное правило формата: bchName -> login + "-NNN" - if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) { - throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName); - } - - if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); - if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); - - this.lineCode = lineCode; - - this.prevLineNumber = prevLineNumber; - this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); - this.thisLineNumber = thisLineNumber; - - this.subType = subType; - this.version = VER; - - this.toBlockchainName = toBlockchainName; - this.toBlockGlobalNumber = toBlockGlobalNumber; - this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); - } - - private static boolean isValidSubType(short st) { - int v = st & 0xFFFF; - return v == (MsgSubType.CONNECTION_FRIEND & 0xFFFF) - || v == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF) - || v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF) - || v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF) - || v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF) - || v == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF); - } - - @Override - public ConnectionBody check() { - if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); - if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF)); - - // line rule (как было) - if (prevLineNumber == -1) { - if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1"); - if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1"); - } else { - if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid"); - } - - if (toBlockchainName == null || toBlockchainName.isBlank()) - throw new IllegalArgumentException("toBlockchainName is blank"); - - // гарантируем вычислимый toLogin (иначе target “битый” по стандарту) - if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) - throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName); - - if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); - if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid"); - - return this; - } - - @Override - public byte[] toBytes() { - byte[] bchBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8); - if (bchBytes.length == 0 || bchBytes.length > 255) - throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255"); - - if (toBlockHash32 == null || toBlockHash32.length != 32) - throw new IllegalArgumentException("toBlockHash32 != 32"); - - int cap = 4 + (4 + 32 + 4) - + 1 + bchBytes.length - + 4 + 32; - - ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); - - bb.putInt(lineCode); - - bb.putInt(prevLineNumber); - bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); - bb.putInt(thisLineNumber); - - bb.put((byte) bchBytes.length); - bb.put(bchBytes); - - bb.putInt(toBlockGlobalNumber); - bb.put(toBlockHash32); - - return bb.array(); - } - - private static boolean isAllZero32(byte[] b) { - if (b == null || b.length != 32) return true; - for (int i = 0; i < 32; i++) if (b[i] != 0) return false; - return true; - } - - /* ====================== BodyHasLine ====================== */ - @Override public int lineCode() { return lineCode; } - @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } - @Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); } - @Override public int lineSeq() { return thisLineNumber; } - - /* ====================== BodyHasTarget ===================== */ - @Override public String toBchName() { return toBlockchainName; } - @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } - @Override public byte[] toBlockHashBytes() { return toBlockHash32; } -} -package blockchain.body; - -import blockchain.MsgSubType; - -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.Objects; - -/** - * CreateChannelBody — TECH сообщение создания канала. - * - * type=0, ver=1 (в заголовке блока) - * subType=MsgSubType.TECH_CREATE_CHANNEL (=1) - * - * Это сообщение идёт по ТЕХ-ЛИНИИ (hasLine): - * - prevLineNumber/hash указывают на предыдущее TECH-сообщение (HEADER или прошлый CREATE_CHANNEL) - * - thisLineNumber: 1,2,3... (тех-нумерация) - * - * bodyBytes (BigEndian), новый формат line-prefix: - * [4] lineCode (для TECH линии обычно 0) - * [4] prevLineNumber - * [32] prevLineHash32 - * [4] thisLineNumber - * [1] channelNameLen (uint8) - * [N] channelName UTF-8 (^[A-Za-z0-9_]+$) - * - * Важно: - * - канал "0" зарезервирован (создаётся по умолчанию от HEADER), создавать его нельзя. - */ -public final class CreateChannelBody implements BodyRecord, BodyHasLine { - - public static final short TYPE = 0; - public static final short VER = 1; - - public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); - - public static final short SUBTYPE = MsgSubType.TECH_CREATE_CHANNEL; - - private static final byte[] ZERO32 = new byte[32]; - - public final short subType; // из header - public final short version; // из header - - // line - public final int lineCode; - public final int prevLineNumber; - public final byte[] prevLineHash32; // 32 - public final int thisLineNumber; - - // payload - public final String channelName; - - public CreateChannelBody(short subType, short version, byte[] bodyBytes) { - Objects.requireNonNull(bodyBytes, "bodyBytes == null"); - - this.subType = subType; - this.version = version; - - if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { - throw new IllegalArgumentException("CreateChannelBody version must be 1, got=" + (this.version & 0xFFFF)); - } - if ((this.subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) { - throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1), got=" + (this.subType & 0xFFFF)); - } - - // минимум: lineCode(4) + line(4+32+4) + nameLen(1) + name(1) - if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1) { - throw new IllegalArgumentException("CreateChannelBody too short"); - } - - ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); - - this.lineCode = bb.getInt(); - - this.prevLineNumber = bb.getInt(); - - this.prevLineHash32 = new byte[32]; - bb.get(this.prevLineHash32); - - this.thisLineNumber = bb.getInt(); - - int nameLen = Byte.toUnsignedInt(bb.get()); - if (nameLen <= 0) throw new IllegalArgumentException("channelNameLen is 0"); - if (bb.remaining() != nameLen) { - throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " nameLen=" + nameLen); - } - - byte[] nameBytes = new byte[nameLen]; - bb.get(nameBytes); - - this.channelName = new String(nameBytes, StandardCharsets.UTF_8); - - if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); - } - - public CreateChannelBody(int lineCode, - int prevLineNumber, - byte[] prevLineHash32, - int thisLineNumber, - String channelName) { - Objects.requireNonNull(channelName, "channelName == null"); - if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); - - this.subType = SUBTYPE; - this.version = VER; - - this.lineCode = lineCode; - this.prevLineNumber = prevLineNumber; - this.prevLineHash32 = (prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32)); - this.thisLineNumber = thisLineNumber; - - this.channelName = channelName; - } - - @Override - public CreateChannelBody check() { - if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); - - if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) - throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1)"); - - if (channelName == null || channelName.isBlank()) - throw new IllegalArgumentException("channelName is blank"); - - if (!channelName.matches("^[A-Za-z0-9_]+$")) - throw new IllegalArgumentException("channelName must match ^[A-Za-z0-9_]+$"); - - if ("0".equals(channelName)) - throw new IllegalArgumentException("channelName \"0\" is reserved"); - - // tech-line: prev обязателен (минимум HEADER=0) - if (prevLineNumber < 0) - throw new IllegalArgumentException("prevLineNumber must be >=0 for CreateChannelBody"); - if (prevLineHash32 == null || prevLineHash32.length != 32) - throw new IllegalArgumentException("prevLineHash32 invalid"); - if (thisLineNumber <= 0) - throw new IllegalArgumentException("thisLineNumber must be >=1 for CreateChannelBody"); - - return this; - } - - @Override - public byte[] toBytes() { - byte[] nameUtf8 = channelName.getBytes(StandardCharsets.UTF_8); - if (nameUtf8.length == 0 || nameUtf8.length > 255) - throw new IllegalArgumentException("channelName utf8 len must be 1..255"); - - int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length; - ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); - - bb.putInt(lineCode); - - bb.putInt(prevLineNumber); - bb.put(prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32)); - bb.putInt(thisLineNumber); - - bb.put((byte) nameUtf8.length); - bb.put(nameUtf8); - - return bb.array(); - } - - /* ====================== BodyHasLine ====================== */ - @Override public int lineCode() { return lineCode; } - @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } - @Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); } - @Override public int lineSeq() { return thisLineNumber; } -} -package blockchain.body; - -import utils.config.ShineSignatureConstants; - -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.charset.StandardCharsets; -import java.util.Objects; - -/** - * HeaderBody — type=0, version=1. - * - * В новом формате type/subType/version живут в HEADER блока, - * поэтому bodyBytes для HeaderBody содержат только payload: - * - * bodyBytes (BigEndian): - * [TAG_LEN] tag ASCII "SHiNE" - * [1] loginLength=N (uint8) - * [N] login UTF-8 - */ -public final class HeaderBody implements BodyRecord { - - public static final short TYPE = 0; - public static final short VER = 1; - - public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); - - /** Для header subType всегда 0 (служебная совместимость). */ - public static final short SUBTYPE_COMPAT = 0; - - /** TAG формата (ASCII). */ - public static final String TAG = ShineSignatureConstants.BLOCKCHAIN_HEADER_TAG; - - private static final byte[] TAG_ASCII = TAG.getBytes(StandardCharsets.US_ASCII); - private static final int TAG_LEN = TAG_ASCII.length; - - public final short subType; // всегда 0 (из заголовка блока) - public final short version; // из заголовка блока - public final String tag; // "SHiNE" - public final String login; - - /** Десериализация из payload bodyBytes (без type/subType/version). */ - public HeaderBody(short subType, short version, byte[] bodyBytes) { - Objects.requireNonNull(bodyBytes, "bodyBytes == null"); - - this.subType = subType; - this.version = version; - - if ((this.subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF)) { - throw new IllegalArgumentException("HeaderBody subType must be 0, got=" + (this.subType & 0xFFFF)); - } - if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { - throw new IllegalArgumentException("HeaderBody version must be 1, got=" + (this.version & 0xFFFF)); - } - - // минимум: tag[TAG_LEN] + loginLen[1] - if (bodyBytes.length < TAG_LEN + 1) throw new IllegalArgumentException("HeaderBody too short"); - - ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); - - byte[] tagBytes = new byte[TAG_LEN]; - bb.get(tagBytes); - String t = new String(tagBytes, StandardCharsets.US_ASCII); - if (!TAG.equals(t)) throw new IllegalArgumentException("Bad tag: " + t); - this.tag = t; - - int loginLen = Byte.toUnsignedInt(bb.get()); - if (loginLen <= 0 || bb.remaining() < loginLen) - throw new IllegalArgumentException("Bad login length"); - - byte[] loginBytes = new byte[loginLen]; - bb.get(loginBytes); - this.login = new String(loginBytes, StandardCharsets.UTF_8); - - if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); - } - - /** Создание “вручную”. */ - public HeaderBody(String login) { - Objects.requireNonNull(login, "login == null"); - this.subType = SUBTYPE_COMPAT; - this.version = VER; - this.tag = TAG; - this.login = login; - } - - @Override - public HeaderBody check() { - if ((subType & 0xFFFF) != (SUBTYPE_COMPAT & 0xFFFF)) - throw new IllegalArgumentException("HeaderBody subType must be 0"); - - if (login == null || login.isBlank()) - throw new IllegalArgumentException("Login is blank"); - if (!login.matches("^[A-Za-z0-9_]+$")) - throw new IllegalArgumentException("Login must match ^[A-Za-z0-9_]+$"); - - return this; - } - - @Override - public byte[] toBytes() { - byte[] loginUtf8 = login.getBytes(StandardCharsets.UTF_8); - if (loginUtf8.length == 0 || loginUtf8.length > 255) - throw new IllegalArgumentException("Login utf8 len must be 1..255"); - - int cap = TAG_LEN + 1 + loginUtf8.length; - - ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); - bb.put(TAG_ASCII); - bb.put((byte) loginUtf8.length); - bb.put(loginUtf8); - - return bb.array(); - } - - @Override - public String toString() { - return """ - HeaderBody { - тип записи : HEADER (type=0, ver=1) [в заголовке блока] - subType : 0 (compat) - тег формата : "%s" - login владельца : "%s" - } - """.formatted(tag, login); - } -} -package blockchain.body; - -import blockchain.MsgSubType; - -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.Objects; - -/** - * ReactionBody — type=2, version=1 (в заголовке блока). - * - * subType (в заголовке блока): - * 1 = LIKE - * - * bodyBytes (BigEndian), новый формат: - * [1] toBlockchainNameLen (uint8) - * [N] toBlockchainName UTF-8 - * [4] toBlockGlobalNumber (int32) - * [32] toBlockHash32 (raw 32 bytes) - * - * ЛИНИИ НЕТ. - */ -public final class ReactionBody implements BodyRecord, BodyHasTarget { - - public static final short TYPE = 2; - public static final short VER = 1; - - public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); - - public final short subType; // из header - public final short version; // из header - - public final String toBlockchainName; - public final int toBlockGlobalNumber; - public final byte[] toBlockHash32; - - public ReactionBody(short subType, short version, byte[] bodyBytes) { - Objects.requireNonNull(bodyBytes, "bodyBytes == null"); - - this.subType = subType; - this.version = version; - - if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { - throw new IllegalArgumentException("ReactionBody version must be 1, got=" + (this.version & 0xFFFF)); - } - if ((this.subType & 0xFFFF) != (MsgSubType.REACTION_LIKE & 0xFFFF)) { - throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF)); - } - - // минимум: nameLen[1]+name[1]+global[4]+hash[32] - if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short"); - - ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); - - int nameLen = Byte.toUnsignedInt(bb.get()); - if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0"); - if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short"); - - byte[] nameBytes = new byte[nameLen]; - bb.get(nameBytes); - this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); - - this.toBlockGlobalNumber = bb.getInt(); - - this.toBlockHash32 = new byte[32]; - bb.get(this.toBlockHash32); - - if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); - } - - public ReactionBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) { - Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); - Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); - - this.subType = MsgSubType.REACTION_LIKE; - this.version = VER; - - if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); - if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); - if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); - - this.toBlockchainName = toBlockchainName; - this.toBlockGlobalNumber = toBlockGlobalNumber; - this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); - } - - @Override - public ReactionBody check() { - if ((subType & 0xFFFF) != (MsgSubType.REACTION_LIKE & 0xFFFF)) - throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF)); - - if (toBlockchainName == null || toBlockchainName.isBlank()) - throw new IllegalArgumentException("toBlockchainName is blank"); - if (toBlockGlobalNumber < 0) - throw new IllegalArgumentException("toBlockGlobalNumber < 0"); - if (toBlockHash32 == null || toBlockHash32.length != 32) - throw new IllegalArgumentException("toBlockHash32 invalid"); - - return this; - } - - @Override - public byte[] toBytes() { - byte[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8); - if (nameBytes.length == 0 || nameBytes.length > 255) - throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255"); - - int cap = 1 + nameBytes.length + 4 + 32; - - ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); - bb.put((byte) nameBytes.length); - bb.put(nameBytes); - bb.putInt(toBlockGlobalNumber); - bb.put(toBlockHash32); - - return bb.array(); - } - - /* ====================== BodyHasTarget ====================== */ - - @Override public String toBchName() { return toBlockchainName; } - @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } - @Override public byte[] toBlockHashBytes() { return toBlockHash32; } -} -package blockchain; - -import blockchain.body.*; - -/** - * Парсер body выбирает класс по header: type/subType/version, - * потому что bodyBytes больше НЕ содержат type/subType/version. - */ -public final class BodyRecordParser { - - private BodyRecordParser() {} - - public static BodyRecord parse(short type, short subType, short version, byte[] bodyBytes) { - if (bodyBytes == null) throw new IllegalArgumentException("bodyBytes == null"); - - int t = type & 0xFFFF; - int v = version & 0xFFFF; - - int key = (t << 16) | v; - - BodyRecord r = switch (key) { - case HeaderBody.KEY -> { - int st = subType & 0xFFFF; - if (st == (HeaderBody.SUBTYPE_COMPAT & 0xFFFF)) { - yield new HeaderBody(subType, version, bodyBytes); - } - if (st == (CreateChannelBody.SUBTYPE & 0xFFFF)) { - yield new CreateChannelBody(subType, version, bodyBytes); - } - throw new IllegalArgumentException("Unknown TECH subType for type=0 ver=1: subType=" + st); - } - - // TEXT type=1 ver=1: выбираем класс по subType - case TextBody.KEY -> { - int st = subType & 0xFFFF; - - if (st == (MsgSubType.TEXT_POST & 0xFFFF) - || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - yield new TextLineBody(subType, version, bodyBytes); - } - - if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) - || st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { - yield new TextReplyBody(subType, version, bodyBytes); - } - - throw new IllegalArgumentException("Unknown TEXT subType for type=1 ver=1: subType=" + st); - } - - case ReactionBody.KEY -> new ReactionBody(subType, version, bodyBytes); - case ConnectionBody.KEY -> new ConnectionBody(subType, version, bodyBytes); - case UserParamBody.KEY -> new UserParamBody(subType, version, bodyBytes); - - default -> throw new IllegalArgumentException(String.format( - "Unknown body type/version from header: type=%d ver=%d subType=%d", - t, v, (subType & 0xFFFF) - )); - }; - - return r.check(); - } -} -package blockchain.body; - -import blockchain.MsgSubType; - -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.charset.CharacterCodingException; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.Objects; - -/** - * TextBody — type=1, ver=1 (в заголовке блока). - * - * subType (в заголовке блока): - * 10 = POST - * 11 = EDIT_POST - * 20 = REPLY - * 21 = EDIT_REPLY - * - * ========================================================================= - * КОНЦЕПЦИЯ ЛИНИЙ ДЛЯ ТЕКСТОВЫХ СООБЩЕНИЙ: - * - * POST и EDIT_POST принадлежат ЛИНИИ КАНАЛА и имеют hasLine. - * В новом формате добавлен lineCode: - * lineCode = 0 для канала "0" - * lineCode = blockNumber "заглавия линии/канала" (например CREATE_CHANNEL) - * - * REPLY и EDIT_REPLY НЕ имеют линии (нет hasLine в байтах). - * - * ========================================================================= - * ФОРМАТЫ bodyBytes (BigEndian): - * - * 1) POST (subType=10): - * [4] lineCode - * [4] prevLineNumber - * [32] prevLineHash32 - * [4] thisLineNumber - * [2] textLenBytes (uint16) - * [N] text UTF-8 - * - * 2) EDIT_POST (subType=11): - * [4] lineCode - * [4] prevLineNumber - * [32] prevLineHash32 - * [4] thisLineNumber - * - * hasTarget (на ОРИГИНАЛЬНЫЙ POST, toBchName НЕ хранить): - * [4] toBlockGlobalNumber - * [32] toBlockHash32 - * - * [2] textLenBytes (uint16) - * [N] text UTF-8 - * - * 3) REPLY (subType=20) — НЕ в линии: - * hasTarget: - * [1] toBlockchainNameLen (uint8) - * [N] toBlockchainName UTF-8 - * [4] toBlockGlobalNumber - * [32] toBlockHash32 - * - * [2] textLenBytes (uint16) - * [M] text UTF-8 - * - * 4) EDIT_REPLY (subType=21) — НЕ в линии: - * hasTarget (на ОРИГИНАЛЬНЫЙ REPLY, toBchName НЕ хранить): - * [4] toBlockGlobalNumber - * [32] toBlockHash32 - * - * [2] textLenBytes (uint16) - * [N] text UTF-8 - */ -public final class TextBody implements BodyRecord, BodyHasTarget, BodyHasLine { - - public static final short TYPE = 1; - public static final short VER = 1; - - public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); - - public final short subType; // из header - public final short version; // из header - - // ===== line fields (только для POST/EDIT_POST) ===== - // Для REPLY/EDIT_REPLY эти поля НЕ сериализуются; значения держим как "пустые". - public final int lineCode; // только для line-message; иначе -1 - public final int prevLineNumber; - public final byte[] prevLineHash32; // 32 or null - public final int thisLineNumber; - - // ===== message text ===== - public final String message; - - // ===== target fields ===== - // REPLY: toBlockchainName + globalNumber + hash32 - // EDIT_POST / EDIT_REPLY: только globalNumber + hash32 (без toBlockchainName) - public final String toBlockchainName; // nullable - public final Integer toBlockGlobalNumber; // nullable - public final byte[] toBlockHash32; // nullable (но если target есть -> 32) - - /* ===================================================================== */ - /* ====================== Конструктор из байт ========================== */ - /* ===================================================================== */ - - public TextBody(short subType, short version, byte[] bodyBytes) { - Objects.requireNonNull(bodyBytes, "bodyBytes == null"); - - this.subType = subType; - this.version = version; - - if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { - throw new IllegalArgumentException("TextBody version must be 1, got=" + (this.version & 0xFFFF)); - } - if (!isValidSubType(this.subType)) { - throw new IllegalArgumentException("Bad Text subType: " + (this.subType & 0xFFFF)); - } - - ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); - - int st = this.subType & 0xFFFF; - - if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { - // POST: hasLine(lineCode+line) + text - ensureMin(bb, (4 + 4 + 32 + 4) + 2, "POST too short"); - - this.lineCode = bb.getInt(); - this.prevLineNumber = bb.getInt(); - this.prevLineHash32 = new byte[32]; - bb.get(this.prevLineHash32); - this.thisLineNumber = bb.getInt(); - - this.message = readStrictUtf8Len16(bb, "POST text"); - - this.toBlockchainName = null; - this.toBlockGlobalNumber = null; - this.toBlockHash32 = null; - - ensureNoTail(bb, "POST"); - - } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - // EDIT_POST: hasLine(lineCode+line) + target(no bch) + text - ensureMin(bb, (4 + 4 + 32 + 4) + (4 + 32) + 2, "EDIT_POST too short"); - - this.lineCode = bb.getInt(); - this.prevLineNumber = bb.getInt(); - this.prevLineHash32 = new byte[32]; - bb.get(this.prevLineHash32); - this.thisLineNumber = bb.getInt(); - - int tgtNum = bb.getInt(); - byte[] tgtHash = new byte[32]; - bb.get(tgtHash); - - this.toBlockchainName = null; - this.toBlockGlobalNumber = tgtNum; - this.toBlockHash32 = tgtHash; - - this.message = readStrictUtf8Len16(bb, "EDIT_POST text"); - - ensureNoTail(bb, "EDIT_POST"); - - } else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { - // REPLY: target(with bch) + text (без line) - ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short"); - - int nameLen = Byte.toUnsignedInt(bb.get()); - if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0"); - ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short"); - - byte[] nameBytes = new byte[nameLen]; - bb.get(nameBytes); - this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); - - this.toBlockGlobalNumber = bb.getInt(); - - this.toBlockHash32 = new byte[32]; - bb.get(this.toBlockHash32); - - this.message = readStrictUtf8Len16(bb, "REPLY text"); - - // line fields отсутствуют в байтах - this.lineCode = -1; - this.prevLineNumber = -1; - this.prevLineHash32 = null; - this.thisLineNumber = -1; - - ensureNoTail(bb, "REPLY"); - - } else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { - // EDIT_REPLY: target(no bch) + text (без line) - ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short"); - - int tgtNum = bb.getInt(); - byte[] tgtHash = new byte[32]; - bb.get(tgtHash); - - this.toBlockchainName = null; - this.toBlockGlobalNumber = tgtNum; - this.toBlockHash32 = tgtHash; - - this.message = readStrictUtf8Len16(bb, "EDIT_REPLY text"); - - // line fields отсутствуют в байтах - this.lineCode = -1; - this.prevLineNumber = -1; - this.prevLineHash32 = null; - this.thisLineNumber = -1; - - ensureNoTail(bb, "EDIT_REPLY"); - - } else { - throw new IllegalArgumentException("Unsupported Text subType: " + st); - } - } - - /* ===================================================================== */ - /* ====================== Фабрики (удобно) ============================= */ - /* ===================================================================== */ - - public static TextBody newPost(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, String message) { - return new TextBody(MsgSubType.TEXT_POST, lineCode, prevLineNumber, prevLineHash32, thisLineNumber, - message, null, null, null); - } - - public static TextBody newEditPost(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, - int targetBlockNumber, byte[] targetHash32, - String message) { - return new TextBody(MsgSubType.TEXT_EDIT_POST, lineCode, prevLineNumber, prevLineHash32, thisLineNumber, - message, null, targetBlockNumber, targetHash32); - } - - public static TextBody newReply(String toBlockchainName, int targetBlockNumber, byte[] targetHash32, String message) { - return new TextBody(MsgSubType.TEXT_REPLY, -1, -1, null, -1, - message, toBlockchainName, targetBlockNumber, targetHash32); - } - - public static TextBody newEditReply(int targetBlockNumber, byte[] targetHash32, String message) { - return new TextBody(MsgSubType.TEXT_EDIT_REPLY, -1, -1, null, -1, - message, null, targetBlockNumber, targetHash32); - } - - /** - * Универсальный конструктор “вручную”. - * Для REPLY/EDIT_REPLY line поля игнорируются при сериализации (их в формате нет). - */ - public TextBody(short subType, - int lineCode, - int prevLineNumber, - byte[] prevLineHash32, - int thisLineNumber, - String message, - String toBlockchainName, - Integer toBlockGlobalNumber, - byte[] toBlockHash32) { - - Objects.requireNonNull(message, "message == null"); - - if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad Text subType: " + (subType & 0xFFFF)); - if (message.isBlank()) throw new IllegalArgumentException("message is blank"); - - this.subType = subType; - this.version = VER; - - int st = subType & 0xFFFF; - - // line применима только к POST/EDIT_POST - if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0 for line message"); - this.lineCode = lineCode; - this.prevLineNumber = prevLineNumber; - this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); - this.thisLineNumber = thisLineNumber; - } else { - this.lineCode = -1; - this.prevLineNumber = -1; - this.prevLineHash32 = null; - this.thisLineNumber = -1; - } - - this.message = message; - - // target правила - if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { - this.toBlockchainName = null; - this.toBlockGlobalNumber = null; - this.toBlockHash32 = null; - - } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); - Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); - if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); - if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); - - this.toBlockchainName = null; // по ТЗ: не хранить - this.toBlockGlobalNumber = toBlockGlobalNumber; - this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); - - } else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { - Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); - Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); - Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); - if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); - if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); - if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); - - this.toBlockchainName = toBlockchainName; - this.toBlockGlobalNumber = toBlockGlobalNumber; - this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); - - } else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { - Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); - Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); - if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); - if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); - - this.toBlockchainName = null; // по ТЗ: не хранить - this.toBlockGlobalNumber = toBlockGlobalNumber; - this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); - - } else { - this.toBlockchainName = null; - this.toBlockGlobalNumber = null; - this.toBlockHash32 = null; - } - } - - private static boolean isValidSubType(short st) { - int v = st & 0xFFFF; - return v == (MsgSubType.TEXT_POST & 0xFFFF) - || v == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) - || v == (MsgSubType.TEXT_REPLY & 0xFFFF) - || v == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF); - } - - @Override - public TextBody check() { - if (!isValidSubType(subType)) - throw new IllegalArgumentException("Bad Text subType: " + (subType & 0xFFFF)); - - if (message == null || message.isBlank()) - throw new IllegalArgumentException("Text message is blank"); - - int st = subType & 0xFFFF; - - // локальные проверки line (БД не трогаем) - if (st == (MsgSubType.TEXT_POST & 0xFFFF) || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0 for line message"); - if (prevLineHash32 == null || prevLineHash32.length != 32) - throw new IllegalArgumentException("prevLineHash32 invalid"); - } else { - // reply/edit_reply: line отсутствует - if (prevLineHash32 != null) - throw new IllegalArgumentException("REPLY/EDIT_REPLY must not contain line hash"); - } - - // target rules - if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { - if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null) - throw new IllegalArgumentException("POST must not contain target fields"); - - } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - if (toBlockchainName != null) - throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName in target"); - if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0) - throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid"); - if (toBlockHash32 == null || toBlockHash32.length != 32) - throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid"); - - } else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { - if (toBlockchainName == null || toBlockchainName.isBlank()) - throw new IllegalArgumentException("REPLY toBlockchainName is blank"); - if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0) - throw new IllegalArgumentException("REPLY toBlockGlobalNumber invalid"); - if (toBlockHash32 == null || toBlockHash32.length != 32) - throw new IllegalArgumentException("REPLY toBlockHash32 invalid"); - - } else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { - if (toBlockchainName != null) - throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName in target"); - if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0) - throw new IllegalArgumentException("EDIT_REPLY toBlockGlobalNumber invalid"); - if (toBlockHash32 == null || toBlockHash32.length != 32) - throw new IllegalArgumentException("EDIT_REPLY toBlockHash32 invalid"); - } - - return this; - } - - @Override - public byte[] toBytes() { - byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); - if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty"); - if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); - - int st = subType & 0xFFFF; - - if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { - // hasLine(lineCode+line) + text - int cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length; - - ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); - bb.putInt(lineCode); - bb.putInt(prevLineNumber); - bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); - bb.putInt(thisLineNumber); - bb.putShort((short) msgUtf8.length); - bb.put(msgUtf8); - return bb.array(); - - } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - // hasLine(lineCode+line) + target(no bch) + text - if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber"); - if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32"); - - int cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length; - - ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); - bb.putInt(lineCode); - bb.putInt(prevLineNumber); - bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); - bb.putInt(thisLineNumber); - - bb.putInt(toBlockGlobalNumber); - bb.put(toBlockHash32); - - bb.putShort((short) msgUtf8.length); - bb.put(msgUtf8); - return bb.array(); - - } else if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { - // target(with bch) + text - if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName"); - if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPLY missing toBlockGlobalNumber"); - if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPLY toBlockHash32 != 32"); - - byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); - if (nameUtf8.length == 0 || nameUtf8.length > 255) - throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255"); - - int cap = 1 + nameUtf8.length + 4 + 32 - + 2 + msgUtf8.length; - - ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); - bb.put((byte) nameUtf8.length); - bb.put(nameUtf8); - bb.putInt(toBlockGlobalNumber); - bb.put(toBlockHash32); - - bb.putShort((short) msgUtf8.length); - bb.put(msgUtf8); - return bb.array(); - - } else if (st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { - // target(no bch) + text - if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_REPLY missing toBlockGlobalNumber"); - if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_REPLY toBlockHash32 != 32"); - - int cap = (4 + 32) + 2 + msgUtf8.length; - - ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); - bb.putInt(toBlockGlobalNumber); - bb.put(toBlockHash32); - - bb.putShort((short) msgUtf8.length); - bb.put(msgUtf8); - return bb.array(); - - } else { - throw new IllegalStateException("Unsupported Text subType: " + st); - } - } - - /* ===================================================================== */ - /* ========================== Helpers ================================== */ - /* ===================================================================== */ - - private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) { - int len = Short.toUnsignedInt(bb.getShort()); - if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty"); - if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); - - byte[] bytes = new byte[len]; - bb.get(bytes); - - var decoder = StandardCharsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT); - - try { - String s = decoder.decode(ByteBuffer.wrap(bytes)).toString(); - if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); - return s; - } catch (CharacterCodingException e) { - throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e); - } - } - - private static void ensureMin(ByteBuffer bb, int need, String msg) { - if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")"); - } - - private static void ensureNoTail(ByteBuffer bb, String ctx) { - if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining()); - } - - /* ====================== BodyHasLine ====================== */ - @Override public int lineCode() { return lineCode; } - @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } - @Override public byte[] prevLineBlockHash32() { - if (prevLineHash32 == null) return null; - return Arrays.copyOf(prevLineHash32, 32); - } - @Override public int lineSeq() { return thisLineNumber; } - - /* ====================== BodyHasTarget ===================== */ - @Override public String toBchName() { return toBlockchainName; } - @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } - @Override public byte[] toBlockHashBytes() { return toBlockHash32; } - - /* ===================================================================== */ - /* ===================== Удобные хелперы (для ChainState) =============== */ - /* ===================================================================== */ - - /** true только для POST / EDIT_POST (т.е. это сообщение в линии канала). */ - public boolean isLineMessage() { - int st = subType & 0xFFFF; - return st == (MsgSubType.TEXT_POST & 0xFFFF) - || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF); - } - - /** true только для EDIT_POST / EDIT_REPLY. */ - public boolean isEditMessage() { - int st = subType & 0xFFFF; - return st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) - || st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF); - } - - /** true только для REPLY / EDIT_REPLY (т.е. “не в линии”). */ - public boolean isReplyFamily() { - int st = subType & 0xFFFF; - return st == (MsgSubType.TEXT_REPLY & 0xFFFF) - || st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF); - } -} -package blockchain.body; - -import blockchain.MsgSubType; - -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.charset.CharacterCodingException; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.Objects; - -/** - * TextLineBody — type=1, ver=1. - * - * subType: - * - POST (10) - * - EDIT_POST (11) - * - * Формат bodyBytes (BigEndian): - * - * POST: - * [4] lineCode - * [4] prevLineNumber - * [32] prevLineHash32 - * [4] thisLineNumber - * [2] textLenBytes (uint16) - * [N] text UTF-8 - * - * EDIT_POST: - * [4] lineCode - * [4] prevLineNumber - * [32] prevLineHash32 - * [4] thisLineNumber - * [4] toBlockGlobalNumber (int32) - * [32] toBlockHash32 - * [2] textLenBytes (uint16) - * [N] text UTF-8 - */ -public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarget { - - public static final short TYPE = 1; - public static final short VER = 1; - - public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); - - public final short subType; // из header - public final short version; // из header (=1) - - // line - public final int lineCode; - public final int prevLineNumber; - public final byte[] prevLineHash32; // 32 (может быть нули) - public final int thisLineNumber; - - // target (только для EDIT_POST) - public final Integer toBlockGlobalNumber; // nullable для POST - public final byte[] toBlockHash32; // nullable для POST - - // text - public final String message; - - /* ====================== parse from bytes ====================== */ - - public TextLineBody(short subType, short version, byte[] bodyBytes) { - Objects.requireNonNull(bodyBytes, "bodyBytes == null"); - - this.subType = subType; - this.version = version; - - if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { - throw new IllegalArgumentException("TextLineBody version must be 1, got=" + (this.version & 0xFFFF)); - } - - int st = this.subType & 0xFFFF; - if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST, got subType=" + st); - } - - ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); - - // минимум line + textLen(2) - ensureMin(bb, (4 + 4 + 32 + 4) + 2, "TextLineBody too short"); - - this.lineCode = bb.getInt(); - this.prevLineNumber = bb.getInt(); - - this.prevLineHash32 = new byte[32]; - bb.get(this.prevLineHash32); - - this.thisLineNumber = bb.getInt(); - - if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - // нужен target - ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target"); - int tgtNum = bb.getInt(); - byte[] tgtHash = new byte[32]; - bb.get(tgtHash); - - this.toBlockGlobalNumber = tgtNum; - this.toBlockHash32 = tgtHash; - - } else { - this.toBlockGlobalNumber = null; - this.toBlockHash32 = null; - } - - this.message = readStrictUtf8Len16(bb, "TextLineBody text"); - - ensureNoTail(bb, "TextLineBody"); - } - - /* ====================== manual ctor ====================== */ - - public TextLineBody(int lineCode, - int prevLineNumber, - byte[] prevLineHash32, - int thisLineNumber, - short subType, - Integer toBlockGlobalNumber, - byte[] toBlockHash32, - String message) { - - Objects.requireNonNull(message, "message == null"); - - int st = subType & 0xFFFF; - if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST"); - } - - if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); - if (message.isBlank()) throw new IllegalArgumentException("message is blank"); - - this.subType = subType; - this.version = VER; - - this.lineCode = lineCode; - this.prevLineNumber = prevLineNumber; - this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); - this.thisLineNumber = thisLineNumber; - - if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); - Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); - if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); - if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); - - this.toBlockGlobalNumber = toBlockGlobalNumber; - this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); - } else { - this.toBlockGlobalNumber = null; - this.toBlockHash32 = null; - } - - this.message = message; - } - - @Override - public TextLineBody check() { - int st = subType & 0xFFFF; - if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) - throw new IllegalArgumentException("Bad TextLineBody subType: " + st); - - if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); - if (prevLineHash32 == null || prevLineHash32.length != 32) - throw new IllegalArgumentException("prevLineHash32 invalid"); - - if (message == null || message.isBlank()) - throw new IllegalArgumentException("Text message is blank"); - - if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0) - throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid"); - if (toBlockHash32 == null || toBlockHash32.length != 32) - throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid"); - } else { - if (toBlockGlobalNumber != null || toBlockHash32 != null) - throw new IllegalArgumentException("POST must not contain target fields"); - } - - return this; - } - - @Override - public byte[] toBytes() { - byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); - if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty"); - if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); - - int st = subType & 0xFFFF; - - int cap; - if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { - cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length; - } else { - // EDIT_POST - if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber"); - if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32"); - cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length; - } - - ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); - - bb.putInt(lineCode); - bb.putInt(prevLineNumber); - bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); - bb.putInt(thisLineNumber); - - if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - bb.putInt(toBlockGlobalNumber); - bb.put(toBlockHash32); - } - - bb.putShort((short) msgUtf8.length); - bb.put(msgUtf8); - - return bb.array(); - } - - /* ====================== BodyHasLine ====================== */ - @Override public int lineCode() { return lineCode; } - @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } - @Override public byte[] prevLineBlockHash32() { return Arrays.copyOf(prevLineHash32, 32); } - @Override public int lineSeq() { return thisLineNumber; } - - /* ====================== BodyHasTarget ===================== */ - @Override public String toBchName() { return null; } // по ТЗ: не хранить - @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } - @Override public byte[] toBlockHashBytes() { return toBlockHash32; } - - /* ====================== helpers ====================== */ - - public boolean isEditPost() { - return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF); - } - - private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) { - int len = Short.toUnsignedInt(bb.getShort()); - if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty"); - if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); - - byte[] bytes = new byte[len]; - bb.get(bytes); - - var decoder = StandardCharsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT); - - try { - String s = decoder.decode(ByteBuffer.wrap(bytes)).toString(); - if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); - return s; - } catch (CharacterCodingException e) { - throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e); - } - } - - private static void ensureMin(ByteBuffer bb, int need, String msg) { - if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")"); - } - - private static void ensureNoTail(ByteBuffer bb, String ctx) { - if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining()); - } -} -package blockchain.body; - -import blockchain.MsgSubType; - -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.charset.CharacterCodingException; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.Objects; - -/** - * TextReplyBody — type=1, ver=1. - * - * subType: - * - REPLY (20) - * - EDIT_REPLY (21) - * - * Форматы bodyBytes (BigEndian): - * - * REPLY: - * [1] toBlockchainNameLen (uint8) - * [N] toBlockchainName UTF-8 - * [4] toBlockGlobalNumber - * [32] toBlockHash32 - * [2] textLenBytes (uint16) - * [M] text UTF-8 - * - * EDIT_REPLY: - * [4] toBlockGlobalNumber - * [32] toBlockHash32 - * [2] textLenBytes (uint16) - * [N] text UTF-8 - */ -public final class TextReplyBody implements BodyRecord, BodyHasTarget { - - public static final short TYPE = 1; - public static final short VER = 1; - - public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); - - public final short subType; // из header - public final short version; // (=1) - - // target - public final String toBlockchainName; // nullable для EDIT_REPLY - public final int toBlockGlobalNumber; - public final byte[] toBlockHash32; // 32 - - // text - public final String message; - - public TextReplyBody(short subType, short version, byte[] bodyBytes) { - Objects.requireNonNull(bodyBytes, "bodyBytes == null"); - - this.subType = subType; - this.version = version; - - if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { - throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF)); - } - - int st = this.subType & 0xFFFF; - if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { - throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st); - } - - ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); - - if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { - // минимум: nameLen[1]+name[1]+global[4]+hash[32]+textLen[2] - ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short"); - - int nameLen = Byte.toUnsignedInt(bb.get()); - if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0"); - ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short"); - - byte[] nameBytes = new byte[nameLen]; - bb.get(nameBytes); - this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); - - this.toBlockGlobalNumber = bb.getInt(); - - this.toBlockHash32 = new byte[32]; - bb.get(this.toBlockHash32); - - } else { - // EDIT_REPLY: target без имени - ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short"); - - this.toBlockchainName = null; - this.toBlockGlobalNumber = bb.getInt(); - - this.toBlockHash32 = new byte[32]; - bb.get(this.toBlockHash32); - } - - this.message = readStrictUtf8Len16(bb, "TextReplyBody text"); - ensureNoTail(bb, "TextReplyBody"); - } - - public TextReplyBody(short subType, - int toBlockGlobalNumber, - byte[] toBlockHash32, - String toBlockchainName, - String message) { - - Objects.requireNonNull(message, "message == null"); - Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); - - int st = subType & 0xFFFF; - if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) { - throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY"); - } - - if (message.isBlank()) throw new IllegalArgumentException("message is blank"); - if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); - if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); - - if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { - Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); - if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); - this.toBlockchainName = toBlockchainName; - } else { - // EDIT_REPLY: имя не хранить - this.toBlockchainName = null; - } - - this.subType = subType; - this.version = VER; - - this.toBlockGlobalNumber = toBlockGlobalNumber; - this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); - - this.message = message; - } - - @Override - public TextReplyBody check() { - int st = subType & 0xFFFF; - if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) - throw new IllegalArgumentException("Bad TextReplyBody subType: " + st); - - if (message == null || message.isBlank()) - throw new IllegalArgumentException("Text message is blank"); - - if (toBlockGlobalNumber < 0) - throw new IllegalArgumentException("toBlockGlobalNumber < 0"); - if (toBlockHash32 == null || toBlockHash32.length != 32) - throw new IllegalArgumentException("toBlockHash32 invalid"); - - if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { - if (toBlockchainName == null || toBlockchainName.isBlank()) - throw new IllegalArgumentException("REPLY toBlockchainName is blank"); - } else { - if (toBlockchainName != null) - throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName"); - } - - return this; - } - - @Override - public byte[] toBytes() { - byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8); - if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty"); - if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)"); - - int st = subType & 0xFFFF; - - if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) { - if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName"); - - byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); - if (nameUtf8.length == 0 || nameUtf8.length > 255) - throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255"); - - int cap = 1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length; - - ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); - bb.put((byte) nameUtf8.length); - bb.put(nameUtf8); - bb.putInt(toBlockGlobalNumber); - bb.put(toBlockHash32); - bb.putShort((short) msgUtf8.length); - bb.put(msgUtf8); - - return bb.array(); - } - - // EDIT_REPLY - int cap = (4 + 32) + 2 + msgUtf8.length; - - ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); - bb.putInt(toBlockGlobalNumber); - bb.put(toBlockHash32); - bb.putShort((short) msgUtf8.length); - bb.put(msgUtf8); - - return bb.array(); - } - - /* ====================== BodyHasTarget ====================== */ - - @Override public String toBchName() { return toBlockchainName; } - @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } - @Override public byte[] toBlockHashBytes() { return toBlockHash32; } - - public boolean isEditReply() { - return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF); - } - - /* ====================== helpers ====================== */ - - private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName) { - int len = Short.toUnsignedInt(bb.getShort()); - if (len <= 0) throw new IllegalArgumentException(fieldName + " is empty"); - if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")"); - - byte[] bytes = new byte[len]; - bb.get(bytes); - - var decoder = StandardCharsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT); - - try { - String s = decoder.decode(ByteBuffer.wrap(bytes)).toString(); - if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank"); - return s; - } catch (CharacterCodingException e) { - throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e); - } - } - - private static void ensureMin(ByteBuffer bb, int need, String msg) { - if (bb.remaining() < need) throw new IllegalArgumentException(msg + " (need=" + need + ", remaining=" + bb.remaining() + ")"); - } - - private static void ensureNoTail(ByteBuffer bb, String ctx) { - if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes for " + ctx + ", remaining=" + bb.remaining()); - } -} -package blockchain.body; - -import blockchain.MsgSubType; - -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.charset.CharacterCodingException; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.Objects; - -/** - * UserParamBody — type=4, ver=1 (в заголовке блока). - * - * subType (в заголовке блока): - * 1 = TEXT_TEXT - * - * bodyBytes (BigEndian), новый формат: - * [4] lineCode - * [4] prevLineNumber - * [32] prevLineHash32 - * [4] thisLineNumber - * - * [2] keyLenBytes (uint16) - * [N] keyUtf8 - * - * [2] valueLenBytes (uint16) - * [M] valueUtf8 - */ -public final class UserParamBody implements BodyRecord, BodyHasLine { - - public static final short TYPE = 4; - public static final short VER = 1; - - public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); - - public final short subType; // из header - public final short version; // из header - - // line - public final int lineCode; - public final int prevLineNumber; - public final byte[] prevLineHash32; - public final int thisLineNumber; - - public final String paramKey; - public final String paramValue; - - public UserParamBody(short subType, short version, byte[] bodyBytes) { - Objects.requireNonNull(bodyBytes, "bodyBytes == null"); - - this.subType = subType; - this.version = version; - - if ((this.version & 0xFFFF) != (VER & 0xFFFF)) { - throw new IllegalArgumentException("UserParamBody version must be 1, got=" + (this.version & 0xFFFF)); - } - if ((this.subType & 0xFFFF) != (MsgSubType.USER_PARAM_TEXT_TEXT & 0xFFFF)) { - throw new IllegalArgumentException("Bad UserParam subType: " + (this.subType & 0xFFFF)); - } - - // минимум: lineCode(4)+line(4+32+4) + keyLen(2)+key(1) + valLen(2)+val(1) - if (bodyBytes.length < 4 + (4 + 32 + 4) + 2 + 1 + 2 + 1) { - throw new IllegalArgumentException("UserParamBody too short"); - } - - ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); - - this.lineCode = bb.getInt(); - - this.prevLineNumber = bb.getInt(); - - this.prevLineHash32 = new byte[32]; - bb.get(this.prevLineHash32); - - this.thisLineNumber = bb.getInt(); - - int keyLen = Short.toUnsignedInt(bb.getShort()); - if (keyLen <= 0) throw new IllegalArgumentException("paramKeyLen is 0"); - if (bb.remaining() < keyLen + 2) throw new IllegalArgumentException("UserParam key payload too short"); - - byte[] keyBytes = new byte[keyLen]; - bb.get(keyBytes); - - int valLen = Short.toUnsignedInt(bb.getShort()); - if (valLen <= 0) throw new IllegalArgumentException("paramValueLen is 0"); - if (bb.remaining() < valLen) throw new IllegalArgumentException("UserParam value payload too short"); - - byte[] valBytes = new byte[valLen]; - bb.get(valBytes); - - if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); - - this.paramKey = strictUtf8(keyBytes, "paramKey"); - this.paramValue = strictUtf8(valBytes, "paramValue"); - - if (this.paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank"); - if (this.paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank"); - } - - public UserParamBody(int lineCode, - int prevLineNumber, - byte[] prevLineHash32, - int thisLineNumber, - String paramKey, - String paramValue) { - - Objects.requireNonNull(paramKey, "paramKey == null"); - Objects.requireNonNull(paramValue, "paramValue == null"); - - if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); - - this.subType = MsgSubType.USER_PARAM_TEXT_TEXT; - this.version = VER; - - this.lineCode = lineCode; - this.prevLineNumber = prevLineNumber; - this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); - this.thisLineNumber = thisLineNumber; - - if (paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank"); - if (paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank"); - - this.paramKey = paramKey; - this.paramValue = paramValue; - } - - @Override - public UserParamBody check() { - if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); - - if ((subType & 0xFFFF) != (MsgSubType.USER_PARAM_TEXT_TEXT & 0xFFFF)) - throw new IllegalArgumentException("Bad UserParam subType: " + (subType & 0xFFFF)); - - if (prevLineNumber == -1) { - if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1"); - if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1"); - } else { - if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid"); - } - - if (paramKey == null || paramKey.isBlank()) throw new IllegalArgumentException("paramKey is blank"); - if (paramValue == null || paramValue.isBlank()) throw new IllegalArgumentException("paramValue is blank"); - - return this; - } - - @Override - public byte[] toBytes() { - byte[] keyUtf8 = paramKey.getBytes(StandardCharsets.UTF_8); - byte[] valUtf8 = paramValue.getBytes(StandardCharsets.UTF_8); - - if (keyUtf8.length == 0 || keyUtf8.length > 65535) throw new IllegalArgumentException("paramKey utf8 len must be 1..65535"); - if (valUtf8.length == 0 || valUtf8.length > 65535) throw new IllegalArgumentException("paramValue utf8 len must be 1..65535"); - - int cap = 4 + (4 + 32 + 4) - + 2 + keyUtf8.length - + 2 + valUtf8.length; - - ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); - - bb.putInt(lineCode); - - bb.putInt(prevLineNumber); - bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32)); - bb.putInt(thisLineNumber); - - bb.putShort((short) keyUtf8.length); - bb.put(keyUtf8); - - bb.putShort((short) valUtf8.length); - bb.put(valUtf8); - - return bb.array(); - } - - private static String strictUtf8(byte[] bytes, String fieldName) { - var decoder = StandardCharsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT); - - try { - return decoder.decode(ByteBuffer.wrap(bytes)).toString(); - } catch (CharacterCodingException e) { - throw new IllegalArgumentException(fieldName + " is not valid UTF-8", e); - } - } - - private static boolean isAllZero32(byte[] b) { - if (b == null || b.length != 32) return true; - for (int i = 0; i < 32; i++) if (b[i] != 0) return false; - return true; - } - - /* ====================== BodyHasLine ====================== */ - @Override public int lineCode() { return lineCode; } - @Override public int prevLineBlockGlobalNumber() { return prevLineNumber; } - @Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); } - @Override public int lineSeq() { return thisLineNumber; } -} -//package blockchain; -// -///** -// * LineIndex — канонические номера линий блокчейна. -// * -// * Линия = независимая последовательность блоков внутри одного блокчейна. -// */ -//public final class LineIndex { -// -// private LineIndex() {} -// -// public static final short HEADER = 0; // genesis / идентификация -// public static final short TEXT = 1; // сообщения да надо -// public static final short REACTION = 2; // реакции не надо -// public static final short CONNECTION = 3; // связи (friend/contact/follow) да надо -// public static final short USER_PARAM = 4; // параметры профиля да надо -//} -package blockchain; - -/** - * MsgSubType — единое место для ВСЕХ subType сообщений (msg_sub_type). - * - * Правило: - * - НИКАКИХ "магических чисел" subType по проекту. - * - В тестах, в body-классах и в SQL-триггерах используем только эти константы. - * - * Важно: - * - Значения менять после релиза нельзя (иначе сломается совместимость). - * - * ========================================================================= - * Про EDIT-типы (важные правила, чтобы не было “двойных правок”): - * - * 1) EDIT разрешён ТОЛЬКО автору (в своём блокчейне). - * Никаких “я отредачу чужое” — нельзя. - * - * 2) EDIT всегда ссылается ТОЛЬКО на ОРИГИНАЛ: - * - EDIT_POST -> на исходный POST - * - EDIT_REPLY -> на исходный REPLY - * НЕЛЬЗЯ ссылаться на предыдущий EDIT (цепочка edit-ов запрещена). - * - * 3) REPLY может ссылаться на блоки в чужих линиях / чужих каналах, - * и существование цели на уровне check() не проверяется - * (check() БД не видит). Если цели нет — “никто не увидит” и ок. - * ========================================================================= - */ -public final class MsgSubType { - - private MsgSubType() {} - - /* ===================== HEADER (msg_type=0) ===================== */ - - /** HeaderBody: subType всегда 0 (compat). */ - public static final short HEADER_COMPAT = 0; - public static final short TECH_CREATE_CHANNEL = 1; - - /* ===================== TEXT (msg_type=1) ===================== */ - - /** - * POST — обычный пост в канале (в линии канала). - * Имеет hasLine (prevLineNumber/prevLineHash32/thisLineNumber). - */ - public static final short TEXT_POST = 10; - - /** - * EDIT_POST — редактирование ПОСТА. - * Имеет hasLine (принадлежит линии канала) - * И имеет target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName). - */ - public static final short TEXT_EDIT_POST = 11; - - /** - * REPLY — ответ на сообщение. - * НЕ в линии. Имеет target (toBlockchainName + blockNumber + hash32). - * Может указывать на чужой блокчейн/чужую линию/чужой канал. - */ - public static final short TEXT_REPLY = 20; - - /** - * EDIT_REPLY — редактирование ОТВЕТА. - * НЕ в линии. Имеет target на ОРИГИНАЛЬНЫЙ REPLY (без toBlockchainName). - */ - public static final short TEXT_EDIT_REPLY = 21; - - /* ===================== REACTION (msg_type=2) ===================== */ - - /** Лайк (LIKE). */ - public static final short REACTION_LIKE = 1; - - /* ===================== CONNECTION (msg_type=3) ===================== */ - - /** Добавить в друзья. */ - public static final short CONNECTION_FRIEND = 10; - /** Удалить из друзей. */ - public static final short CONNECTION_UNFRIEND = 11; - - /** Добавить в контакты. */ - public static final short CONNECTION_CONTACT = 20; - /** Удалить из контактов. */ - public static final short CONNECTION_UNCONTACT = 21; - - /** Подписаться (follow). */ - public static final short CONNECTION_FOLLOW = 30; - /** Отписаться (unfollow). */ - public static final short CONNECTION_UNFOLLOW = 31; - - /* ===================== USER_PARAM (msg_type=4) ===================== */ - - /** Параметр профиля key/value (обе строки). */ - public static final short USER_PARAM_TEXT_TEXT = 1; -} -package utils.blockchain; - -import java.util.Objects; - -public final class BlockchainNameUtil { - - /** - * Теперь новое правило: - * blockchainName = login + "-"+ 3 цифры - * Пример: "Dima-001" -> "Dima" - * - * Сколько символов отрезаем с конца blockchainName, чтобы получить login: "-001" = 4 - */ - public static final int BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN = 4; - - private BlockchainNameUtil() {} - - /** - * Извлечь login из blockchainName: отрезаем последние 4 символа ("-NNN"). - * Пример: "Dima-001" -> "Dima" - */ - public static String loginFromBlockchainName(String blockchainName) { - if (blockchainName == null) return null; - - String s = blockchainName.trim(); - if (!hasDashAnd3DigitsSuffix(s)) return null; - - return s.substring(0, s.length() - BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN); - } - - /** - * Проверка правила: - * - blockchainName должен оканчиваться на "-"+3 цифры - * - blockchainName без суффикса "-NNN" должен равняться login - * - * ВАЖНО: - * - сравнение строгое (case-sensitive) - * - null/blank считаем невалидным - */ - public static boolean isBlockchainNameMatchesLogin(String blockchainName, String login) { - if (blockchainName == null || login == null) return false; - - String bn = blockchainName.trim(); - String lg = login.trim(); - - if (bn.isEmpty() || lg.isEmpty()) return false; - if (!hasDashAnd3DigitsSuffix(bn)) return false; - - String extracted = bn.substring(0, bn.length() - BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN); - return Objects.equals(extracted, lg); - } - - private static boolean hasDashAnd3DigitsSuffix(String s) { - if (s == null) return false; - int len = s.length(); - if (len <= BLOCKCHAIN_NAME_LOGIN_SUFFIX_LEN) return false; - - int dashPos = len - 4; - if (s.charAt(dashPos) != '-') return false; - - char c1 = s.charAt(len - 3); - char c2 = s.charAt(len - 2); - char c3 = s.charAt(len - 1); - - return isDigit(c1) && isDigit(c2) && isDigit(c3); - } - - private static boolean isDigit(char c) { - return c >= '0' && c <= '9'; - } -} -package utils.files; - -import java.io.IOException; -import java.nio.file.*; -import java.util.Objects; - -/** - * =============================================================== - * FileStoreUtil — утилита работы с файлами в папке data/. - * - * Теперь поддерживает: - * - основной файл блокчейна: .bch - * - временный файл блокчейна: .tmp_bch - * - * Важное: - * - validateSimpleFileName() запрещает path traversal. - * - atomicReplaceBlockchainFile(): пытается сделать ATOMIC_MOVE (если ФС поддерживает), - * иначе делает обычный REPLACE_EXISTING move. - * =============================================================== - */ -public final class FileStoreUtil { - - /** Базовая папка для хранения всех файлов (создаётся автоматически). */ - public static final String DATA_DIR_NAME = "data"; - - /** Расширение основного файла блокчейна. */ - public static final String BLOCKCHAIN_FILE_EXTENSION = ".bch"; - - /** Расширение временного файла (старое+новое). */ - public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch"; - - private static final FileStoreUtil INSTANCE = new FileStoreUtil(); - - private final Path dataDirPath; - - private FileStoreUtil() { - this.dataDirPath = Paths.get(DATA_DIR_NAME); - ensureDataDirExists(); - } - - public static FileStoreUtil getInstance() { - return INSTANCE; - } - - /* ===================================================================== */ - /* ======================== Базовые операции =========================== */ - /* ===================================================================== */ - - public void newFile(String fileName, byte[] data) { - Objects.requireNonNull(data, "data == null"); - Path target = resolveSafe(fileName); - try { - Files.write(target, data, - StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING, - StandardOpenOption.WRITE); - } catch (IOException e) { - throw new IllegalStateException("Не удалось записать файл: " + target, e); - } - } - - public void addDataToFile(String fileName, byte[] data) { - Objects.requireNonNull(data, "data == null"); - Path target = resolveSafe(fileName); - try { - Files.write(target, data, - StandardOpenOption.CREATE, - StandardOpenOption.WRITE, - StandardOpenOption.APPEND); - } catch (IOException e) { - throw new IllegalStateException("Не удалось дописать файл: " + target, e); - } - } - - public byte[] readAllDataFromFile(String fileName) { - Path target = resolveSafe(fileName); - if (!Files.exists(target)) { - throw new IllegalStateException("Файл не найден: " + target); - } - try { - return Files.readAllBytes(target); - } catch (IOException e) { - throw new IllegalStateException("Не удалось прочитать файл: " + target, e); - } - } - - public boolean exists(String fileName) { - Path target = resolveSafe(fileName); - return Files.exists(target); - } - - public long size(String fileName) { - Path target = resolveSafe(fileName); - try { - return Files.size(target); - } catch (IOException e) { - throw new IllegalStateException("Не удалось получить размер файла: " + target, e); - } - } - - /* ===================================================================== */ - /* ===================== Блокчейн-файлы по имени ======================= */ - /* ===================================================================== */ - - /** .bch */ - public String buildBlockchainFileName(String blockchainName) { - validateSimpleFileName(blockchainName); - return blockchainName + BLOCKCHAIN_FILE_EXTENSION; - } - - /** .tmp_bch */ - public String buildBlockchainTmpFileName(String blockchainName) { - validateSimpleFileName(blockchainName); - return blockchainName + BLOCKCHAIN_TMP_EXTENSION; - } - - public Path resolveBlockchainPath(String blockchainName) { - return resolveSafe(buildBlockchainFileName(blockchainName)); - } - - public Path resolveBlockchainTmpPath(String blockchainName) { - return resolveSafe(buildBlockchainTmpFileName(blockchainName)); - } - - public byte[] readBlockchain(String blockchainName) { - return readAllDataFromFile(buildBlockchainFileName(blockchainName)); - } - - public void writeBlockchainTmp(String blockchainName, byte[] data) { - newFile(buildBlockchainTmpFileName(blockchainName), data); - } - - /** - * Атомарно заменить основной файл блокчейна временным: - * .tmp_bch -> .bch - * - * Стратегия: - * 1) Пытаемся Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING) - * 2) Если ATOMIC_MOVE не поддерживается — делаем move с REPLACE_EXISTING без атомарности - * - * Важный нюанс: - * - атомарность гарантируется только в пределах одной файловой системы. - */ - public void atomicReplaceBlockchainFile(String blockchainName) { - Path tmp = resolveBlockchainTmpPath(blockchainName); - Path main = resolveBlockchainPath(blockchainName); - - if (!Files.exists(tmp)) { - throw new IllegalStateException("TMP-файл не найден: " + tmp); - } - - try { - // 1) Пытаемся атомарный move - Files.move(tmp, main, - StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.ATOMIC_MOVE); - } catch (AtomicMoveNotSupportedException e) { - // 2) Если ФС не поддерживает атомарный move — делаем обычный replace - try { - Files.move(tmp, main, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException ex) { - throw new IllegalStateException("Не удалось заменить файл блокчейна (non-atomic): " + main, ex); - } - } catch (IOException e) { - throw new IllegalStateException("Не удалось заменить файл блокчейна (atomic): " + main, e); - } - } - - /* ===================================================================== */ - /* ============================ Helpers ================================= */ - /* ===================================================================== */ - - private void ensureDataDirExists() { - try { - if (!Files.exists(dataDirPath)) { - Files.createDirectories(dataDirPath); - } - } catch (IOException e) { - throw new IllegalStateException("Не удалось создать директорию хранения: " + dataDirPath, e); - } - } - - private Path resolveSafe(String fileName) { - validateSimpleFileName(fileName); - return dataDirPath.resolve(fileName); - } - - /** - * Валидация "простого имени": - * - запрещаем слэши, обратные слэши, ".." - * - запрещаем пустоту - * - * Важно: сюда у нас попадает и blockchainName (как часть имени файла), - * поэтому blockchainName должен быть "простым": без путей. - */ - private void validateSimpleFileName(String fileName) { - Objects.requireNonNull(fileName, "fileName == null"); - if (fileName.isBlank()) { - throw new IllegalArgumentException("Имя файла не должно быть пустым"); - } - if (fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) { - throw new IllegalArgumentException("Недопустимое имя файла: " + fileName); - } - } -} diff --git a/SHiNE-server/shine-server-blockchain/concat_to_file.sh b/SHiNE-server/shine-server-blockchain/concat_to_file.sh deleted file mode 100755 index f6db1f1..0000000 --- a/SHiNE-server/shine-server-blockchain/concat_to_file.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# собрать только *.java файлы и вывести их содержимое в файл -find . -type f -name "*.java" | sort | while read -r f; do - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Все .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/SHiNE-server/shine-server-crypto/concat_to_file.sh b/SHiNE-server/shine-server-crypto/concat_to_file.sh deleted file mode 100755 index f6db1f1..0000000 --- a/SHiNE-server/shine-server-crypto/concat_to_file.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# собрать только *.java файлы и вывести их содержимое в файл -find . -type f -name "*.java" | sort | while read -r f; do - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Все .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/SHiNE-server/shine-server-crypto/src/concat_to_file.sh b/SHiNE-server/shine-server-crypto/src/concat_to_file.sh deleted file mode 100755 index f6db1f1..0000000 --- a/SHiNE-server/shine-server-crypto/src/concat_to_file.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# собрать только *.java файлы и вывести их содержимое в файл -find . -type f -name "*.java" | sort | while read -r f; do - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Все .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/SHiNE-server/shine-server-db/all_files.txt b/SHiNE-server/shine-server-db/all_files.txt deleted file mode 100644 index b1511ea..0000000 --- a/SHiNE-server/shine-server-db/all_files.txt +++ /dev/null @@ -1,2832 +0,0 @@ -package shine.db.dao; - -import shine.db.SqliteDbController; -import shine.db.entities.ActiveSessionEntry; - -import java.sql.*; -import java.util.ArrayList; -import java.util.List; - -/** - * DAO для таблицы active_sessions. - * - * Правило: - * - методы с Connection НЕ закрывают соединение - * - методы без Connection сами открывают и закрывают соединение - */ -public final class ActiveSessionsDAO { - - private static volatile ActiveSessionsDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); - - private ActiveSessionsDAO() { } - - public static ActiveSessionsDAO getInstance() { - if (instance == null) { - synchronized (ActiveSessionsDAO.class) { - if (instance == null) instance = new ActiveSessionsDAO(); - } - } - return instance; - } - - // -------------------- INSERT -------------------- - - public void insert(Connection c, ActiveSessionEntry session) throws SQLException { - String sql = """ - INSERT INTO active_sessions ( - session_id, - login, - session_key, - storage_pwd, - session_created_at_ms, - last_authirificated_at_ms, - push_endpoint, - push_p256dh_key, - push_auth_key, - client_ip, - client_info_from_client, - client_info_from_request, - user_language - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, session.getSessionId()); - ps.setString(2, session.getLogin()); - ps.setString(3, session.getSessionKey()); - ps.setString(4, session.getStoragePwd()); - ps.setLong(5, session.getSessionCreatedAtMs()); - ps.setLong(6, session.getLastAuthirificatedAtMs()); - ps.setString(7, session.getPushEndpoint()); - ps.setString(8, session.getPushP256dhKey()); - ps.setString(9, session.getPushAuthKey()); - ps.setString(10, session.getClientIp()); - ps.setString(11, session.getClientInfoFromClient()); - ps.setString(12, session.getClientInfoFromRequest()); - ps.setString(13, session.getUserLanguage()); - ps.executeUpdate(); - } - } - - public void insert(ActiveSessionEntry session) throws SQLException { - try (Connection c = db.getConnection()) { - insert(c, session); - } - } - - // -------------------- SELECT -------------------- - - public ActiveSessionEntry getBySessionId(Connection c, String sessionId) throws SQLException { - String sql = """ - SELECT - session_id, - login, - session_key, - storage_pwd, - session_created_at_ms, - last_authirificated_at_ms, - push_endpoint, - push_p256dh_key, - push_auth_key, - client_ip, - client_info_from_client, - client_info_from_request, - user_language - FROM active_sessions - WHERE session_id = ? - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, sessionId); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return mapRow(rs); - } - } - } - - public ActiveSessionEntry getBySessionId(String sessionId) throws SQLException { - try (Connection c = db.getConnection()) { - return getBySessionId(c, sessionId); - } - } - - public List getByLogin(Connection c, String login) throws SQLException { - String sql = """ - SELECT - session_id, - login, - session_key, - storage_pwd, - session_created_at_ms, - last_authirificated_at_ms, - push_endpoint, - push_p256dh_key, - push_auth_key, - client_ip, - client_info_from_client, - client_info_from_request, - user_language - FROM active_sessions - WHERE login = ? - """; - - List result = new ArrayList<>(); - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, login); - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) result.add(mapRow(rs)); - } - } - - return result; - } - - public List getByLogin(String login) throws SQLException { - try (Connection c = db.getConnection()) { - return getByLogin(c, login); - } - } - - // -------------------- UPDATE -------------------- - - public void updateLastAuthirificatedAtMs(Connection c, String sessionId, long lastAuthMs) throws SQLException { - String sql = """ - UPDATE active_sessions - SET last_authirificated_at_ms = ? - WHERE session_id = ? - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setLong(1, lastAuthMs); - ps.setString(2, sessionId); - ps.executeUpdate(); - } - } - - public void updateLastAuthirificatedAtMs(String sessionId, long lastAuthMs) throws SQLException { - try (Connection c = db.getConnection()) { - updateLastAuthirificatedAtMs(c, sessionId, lastAuthMs); - } - } - - public void updateOnRefresh( - Connection c, - String sessionId, - long lastAuthMs, - String clientIp, - String clientInfoFromClient, - String clientInfoFromRequest, - String userLanguage - ) throws SQLException { - - String sql = """ - UPDATE active_sessions - SET - last_authirificated_at_ms = ?, - client_ip = ?, - client_info_from_client = ?, - client_info_from_request = ?, - user_language = ? - WHERE session_id = ? - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setLong(1, lastAuthMs); - ps.setString(2, clientIp); - ps.setString(3, clientInfoFromClient); - ps.setString(4, clientInfoFromRequest); - ps.setString(5, userLanguage); - ps.setString(6, sessionId); - ps.executeUpdate(); - } - } - - public void updateOnRefresh( - String sessionId, - long lastAuthMs, - String clientIp, - String clientInfoFromClient, - String clientInfoFromRequest, - String userLanguage - ) throws SQLException { - try (Connection c = db.getConnection()) { - updateOnRefresh(c, sessionId, lastAuthMs, clientIp, clientInfoFromClient, clientInfoFromRequest, userLanguage); - } - } - - // -------------------- DELETE -------------------- - - public void deleteBySessionId(Connection c, String sessionId) throws SQLException { - String sql = "DELETE FROM active_sessions WHERE session_id = ?"; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, sessionId); - ps.executeUpdate(); - } - } - - public void deleteBySessionId(String sessionId) throws SQLException { - try (Connection c = db.getConnection()) { - deleteBySessionId(c, sessionId); - } - } - - // -------------------- MAPPER -------------------- - - private ActiveSessionEntry mapRow(ResultSet rs) throws SQLException { - String sessionId = rs.getString("session_id"); - String login = rs.getString("login"); - String sessionKey = rs.getString("session_key"); - String storagePwd = rs.getString("storage_pwd"); - long sessionCreatedAtMs = rs.getLong("session_created_at_ms"); - long lastAuthirificatedAtMs = rs.getLong("last_authirificated_at_ms"); - String pushEndpoint = rs.getString("push_endpoint"); - String pushP256dhKey = rs.getString("push_p256dh_key"); - String pushAuthKey = rs.getString("push_auth_key"); - String clientIp = rs.getString("client_ip"); - String clientInfoFromClient = rs.getString("client_info_from_client"); - String clientInfoFromRequest = rs.getString("client_info_from_request"); - String userLanguage = rs.getString("user_language"); - - return new ActiveSessionEntry( - sessionId, - login, - sessionKey, - storagePwd, - sessionCreatedAtMs, - lastAuthirificatedAtMs, - pushEndpoint, - pushP256dhKey, - pushAuthKey, - clientIp, - clientInfoFromClient, - clientInfoFromRequest, - userLanguage - ); - } -} -package shine.db.dao; - -import shine.db.SqliteDbController; -import shine.db.entities.BlockchainStateEntry; - -import java.sql.*; - -public final class BlockchainStateDAO { - - private static volatile BlockchainStateDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); - - private BlockchainStateDAO() {} - - public static BlockchainStateDAO getInstance() { - if (instance == null) { - synchronized (BlockchainStateDAO.class) { - if (instance == null) instance = new BlockchainStateDAO(); - } - } - return instance; - } - - /** Получить по blockchainName без внешнего соединения. Сам открывает/закрывает. */ - public BlockchainStateEntry getByBlockchainName(String blockchainName) throws SQLException { - try (Connection c = db.getConnection()) { - return getByBlockchainName(c, blockchainName); - } - } - - /** Получить по blockchainName с внешним соединением. Соединение НЕ закрывает. */ - public BlockchainStateEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException { - String sql = """ - SELECT - blockchain_name, - login, - blockchain_key, - size_limit, - file_size_bytes, - last_block_number, - last_block_hash, - updated_at_ms - FROM blockchain_state - WHERE blockchain_name = ? - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, blockchainName); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return mapRow(rs); - } - } - } - - /** UPSERT без внешнего соединения. Сам открывает/закрывает. */ - public void upsert(BlockchainStateEntry e) throws SQLException { - try (Connection c = db.getConnection()) { - upsert(c, e); - } - } - - /** UPSERT с внешним соединением. Соединение НЕ закрывает. */ - public void upsert(Connection c, BlockchainStateEntry e) throws SQLException { - String sql = """ - INSERT INTO blockchain_state ( - blockchain_name, - login, - blockchain_key, - size_limit, - file_size_bytes, - last_block_number, - last_block_hash, - updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(blockchain_name) - DO UPDATE SET - login = excluded.login, - blockchain_key = excluded.blockchain_key, - size_limit = excluded.size_limit, - file_size_bytes = excluded.file_size_bytes, - last_block_number= excluded.last_block_number, - last_block_hash = excluded.last_block_hash, - updated_at_ms = excluded.updated_at_ms - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - int i = 1; - - ps.setString(i++, e.getBlockchainName()); - ps.setString(i++, nn(e.getLogin())); - ps.setString(i++, nn(e.getBlockchainKey())); - - ps.setLong(i++, e.getSizeLimit()); - ps.setLong(i++, e.getFileSizeBytes()); - - ps.setInt(i++, e.getLastBlockNumber()); - setBytesNullable(ps, i++, e.getLastBlockHash()); - - ps.setLong(i++, e.getUpdatedAtMs()); - - ps.executeUpdate(); - } - } - - /** - * Атомарно увеличить file_size_bytes на deltaBytes, но только если НЕ превысим size_limit. - */ - public boolean tryIncreaseFileSizeWithinLimit(Connection c, String blockchainName, long deltaBytes, long nowMs) throws SQLException { - String sql = """ - UPDATE blockchain_state - SET - file_size_bytes = file_size_bytes + ?, - updated_at_ms = ? - WHERE - blockchain_name = ? - AND (file_size_bytes + ?) <= size_limit - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setLong(1, deltaBytes); - ps.setLong(2, nowMs); - ps.setString(3, blockchainName); - ps.setLong(4, deltaBytes); - return ps.executeUpdate() > 0; - } - } - - private BlockchainStateEntry mapRow(ResultSet rs) throws SQLException { - BlockchainStateEntry e = new BlockchainStateEntry(); - - e.setBlockchainName(rs.getString("blockchain_name")); - e.setLogin(rs.getString("login")); - e.setBlockchainKey(rs.getString("blockchain_key")); - - e.setSizeLimit(rs.getLong("size_limit")); - e.setFileSizeBytes(rs.getLong("file_size_bytes")); - - e.setLastBlockNumber(rs.getInt("last_block_number")); - e.setLastBlockHash(rs.getBytes("last_block_hash")); // nullable - - e.setUpdatedAtMs(rs.getLong("updated_at_ms")); - - return e; - } - - private static void setBytesNullable(PreparedStatement ps, int index, byte[] b) throws SQLException { - if (b != null) ps.setBytes(index, b); - else ps.setNull(index, Types.BLOB); - } - - private static String nn(String s) { return s == null ? "" : s; } -} -package shine.db.dao; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import shine.db.SqliteDbController; -import shine.db.entities.BlockEntry; - -import java.sql.*; - -/** - * DAO для таблицы blocks (новый формат). - * - * Правило: - * - методы с Connection НЕ закрывают соединение - * - методы без Connection сами открывают и закрывают соединение - * - * Ключ: - * - (bch_name, block_number) — уникальная пара в рамках общей БД сервера. - */ -public final class BlocksDAO { - - private static volatile BlocksDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); - private static final Logger log = LoggerFactory.getLogger(BlocksDAO.class); - - private BlocksDAO() { } - - public static BlocksDAO getInstance() { - if (instance == null) { - synchronized (BlocksDAO.class) { - if (instance == null) instance = new BlocksDAO(); - } - } - return instance; - } - - // -------------------- INSERT -------------------- - - /** Вставка с внешним соединением. Соединение НЕ закрывает. */ - public void insert(Connection c, BlockEntry e) throws SQLException { - log.info("DBG BlockEntry: type={} sub={} lineCode={} prevLineNumber={} thisLineNumber={} prevLineHashLen={}", - e.getMsgType(), e.getMsgSubType(), - e.getLineCode(), e.getPrevLineNumber(), e.getThisLineNumber(), - e.getPrevLineHash() == null ? null : e.getPrevLineHash().length - ); - - String sql = """ - INSERT INTO blocks ( - login, - bch_name, - block_number, - msg_type, - msg_sub_type, - block_bytes, - to_login, - to_bch_name, - to_block_number, - to_block_hash, - block_hash, - block_signature, - edited_by_block_number, - line_code, - prev_line_number, - prev_line_hash, - this_line_number - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - int i = 1; - - ps.setString(i++, e.getLogin()); - ps.setString(i++, e.getBchName()); - ps.setInt(i++, e.getBlockNumber()); - - ps.setInt(i++, e.getMsgType()); - ps.setInt(i++, e.getMsgSubType()); - - ps.setBytes(i++, e.getBlockBytes()); - - if (e.getToLogin() != null) ps.setString(i++, e.getToLogin()); - else ps.setNull(i++, Types.VARCHAR); - - if (e.getToBchName() != null) ps.setString(i++, e.getToBchName()); - else ps.setNull(i++, Types.VARCHAR); - - if (e.getToBlockNumber() != null) ps.setInt(i++, e.getToBlockNumber()); - else ps.setNull(i++, Types.INTEGER); - - if (e.getToBlockHash() != null) ps.setBytes(i++, e.getToBlockHash()); - else ps.setNull(i++, Types.BLOB); - - ps.setBytes(i++, e.getBlockHash()); - ps.setBytes(i++, e.getBlockSignature()); - - if (e.getEditedByBlockNumber() != null) ps.setInt(i++, e.getEditedByBlockNumber()); - else ps.setNull(i++, Types.INTEGER); - - // NEW: line_code - if (e.getLineCode() != null) ps.setInt(i++, e.getLineCode()); - else ps.setNull(i++, Types.INTEGER); - - if (e.getPrevLineNumber() != null) ps.setInt(i++, e.getPrevLineNumber()); - else ps.setNull(i++, Types.INTEGER); - - if (e.getPrevLineHash() != null) ps.setBytes(i++, e.getPrevLineHash()); - else ps.setNull(i++, Types.BLOB); - - if (e.getThisLineNumber() != null) ps.setInt(i++, e.getThisLineNumber()); - else ps.setNull(i++, Types.INTEGER); - - ps.executeUpdate(); - } - } - - /** Вставка без внешнего соединения. Сам открывает/закрывает. */ - public void insert(BlockEntry e) throws SQLException { - try (Connection c = db.getConnection()) { - insert(c, e); - } - } - - // -------------------- SELECT: HASH BY NUMBER -------------------- - - /** Получить block_hash по (bch_name, block_number). Нужен для линейной проверки. */ - public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException { - String sql = """ - SELECT block_hash - FROM blocks - WHERE bch_name = ? AND block_number = ? - LIMIT 1 - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, bchName); - ps.setInt(2, blockNumber); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return rs.getBytes("block_hash"); - } - } - } - - public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException { - try (Connection c = db.getConnection()) { - return getHashByNumber(c, bchName, blockNumber); - } - } - - // -------------------- SELECT: FULL ENTRY -------------------- - - public BlockEntry getByNumber(Connection c, String bchName, int blockNumber) throws SQLException { - String sql = """ - SELECT - login, - bch_name, - block_number, - msg_type, - msg_sub_type, - block_bytes, - to_login, - to_bch_name, - to_block_number, - to_block_hash, - block_hash, - block_signature, - edited_by_block_number, - line_code, - prev_line_number, - prev_line_hash, - this_line_number - FROM blocks - WHERE bch_name = ? AND block_number = ? - LIMIT 1 - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, bchName); - ps.setInt(2, blockNumber); - - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return mapRow(rs); - } - } - } - - public BlockEntry getByNumber(String bchName, int blockNumber) throws SQLException { - try (Connection c = db.getConnection()) { - return getByNumber(c, bchName, blockNumber); - } - } - - // -------------------- INTERNAL -------------------- - - private BlockEntry mapRow(ResultSet rs) throws SQLException { - BlockEntry e = new BlockEntry(); - - e.setLogin(rs.getString("login")); - e.setBchName(rs.getString("bch_name")); - e.setBlockNumber(rs.getInt("block_number")); - - e.setMsgType(rs.getInt("msg_type")); - e.setMsgSubType(rs.getInt("msg_sub_type")); - - e.setBlockBytes(rs.getBytes("block_bytes")); - - String toLogin = rs.getString("to_login"); - if (rs.wasNull()) toLogin = null; - e.setToLogin(toLogin); - - String toBchName = rs.getString("to_bch_name"); - if (rs.wasNull()) toBchName = null; - e.setToBchName(toBchName); - - Integer toBlockNumber = (Integer) rs.getObject("to_block_number"); - e.setToBlockNumber(toBlockNumber); - - byte[] toHash = rs.getBytes("to_block_hash"); - if (rs.wasNull()) toHash = null; - e.setToBlockHash(toHash); - - e.setBlockHash(rs.getBytes("block_hash")); - e.setBlockSignature(rs.getBytes("block_signature")); - - Integer editedBy = (Integer) rs.getObject("edited_by_block_number"); - e.setEditedByBlockNumber(editedBy); - - // NEW: line_code - Integer lineCode = (Integer) rs.getObject("line_code"); - e.setLineCode(lineCode); - - Integer prevLn = (Integer) rs.getObject("prev_line_number"); - e.setPrevLineNumber(prevLn); - - byte[] prevLh = rs.getBytes("prev_line_hash"); - if (rs.wasNull()) prevLh = null; - e.setPrevLineHash(prevLh); - - Integer thisLn = (Integer) rs.getObject("this_line_number"); - e.setThisLineNumber(thisLn); - - return e; - } -} -package shine.db.dao; - -import shine.db.SqliteDbController; -import shine.db.entities.IpGeoCacheEntry; - -import java.sql.*; - -/** - * DAO для таблицы ip_geo_cache. - * - * Таблица: - * - ip TEXT PRIMARY KEY - * - geo TEXT - * - updated_at_ms INTEGER NOT NULL - * - * Правило: - * - методы с Connection НЕ закрывают соединение - * - методы без Connection сами открывают и закрывают соединение - */ -public final class IpGeoCacheDAO { - - private static volatile IpGeoCacheDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); - - private IpGeoCacheDAO() { } - - public static IpGeoCacheDAO getInstance() { - if (instance == null) { - synchronized (IpGeoCacheDAO.class) { - if (instance == null) instance = new IpGeoCacheDAO(); - } - } - return instance; - } - - // -------------------- UPSERT -------------------- - - /** UPSERT с внешним соединением. Соединение НЕ закрывает. */ - public void upsert(Connection c, IpGeoCacheEntry entry) throws SQLException { - String sql = """ - INSERT INTO ip_geo_cache (ip, geo, updated_at_ms) - VALUES (?, ?, ?) - ON CONFLICT(ip) - DO UPDATE SET - geo = excluded.geo, - updated_at_ms = excluded.updated_at_ms - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, entry.getIp()); - ps.setString(2, entry.getGeo()); - ps.setLong(3, entry.getUpdatedAtMs()); - ps.executeUpdate(); - } - } - - /** UPSERT без внешнего соединения. Сам открывает/закрывает. */ - public void upsert(IpGeoCacheEntry entry) throws SQLException { - try (Connection c = db.getConnection()) { - upsert(c, entry); - } - } - - // -------------------- SELECT -------------------- - - /** Получить по IP с внешним соединением. Соединение НЕ закрывает. */ - public IpGeoCacheEntry getByIp(Connection c, String ip) throws SQLException { - String sql = """ - SELECT ip, geo, updated_at_ms - FROM ip_geo_cache - WHERE ip = ? - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, ip); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return mapRow(rs); - } - } - } - - /** Получить по IP без внешнего соединения. Сам открывает/закрывает. */ - public IpGeoCacheEntry getByIp(String ip) throws SQLException { - try (Connection c = db.getConnection()) { - return getByIp(c, ip); - } - } - - // -------------------- DELETE -------------------- - - /** Удалить старые записи с внешним соединением. Соединение НЕ закрывает. */ - public int deleteOlderThan(Connection c, long thresholdMs) throws SQLException { - String sql = "DELETE FROM ip_geo_cache WHERE updated_at_ms < ?"; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setLong(1, thresholdMs); - return ps.executeUpdate(); - } - } - - /** Удалить старые записи без внешнего соединения. Сам открывает/закрывает. */ - public int deleteOlderThan(long thresholdMs) throws SQLException { - try (Connection c = db.getConnection()) { - return deleteOlderThan(c, thresholdMs); - } - } - - // -------------------- MAPPER -------------------- - - private IpGeoCacheEntry mapRow(ResultSet rs) throws SQLException { - String ip = rs.getString("ip"); - String geo = rs.getString("geo"); - long updatedAtMs = rs.getLong("updated_at_ms"); - return new IpGeoCacheEntry(ip, geo, updatedAtMs); - } -} -package shine.db.dao; - -import shine.db.SqliteDbController; -import shine.db.entities.SolanaUserEntry; - -import java.sql.*; -import java.util.ArrayList; -import java.util.List; - -/** - * SolanaUsersDAO — локальная таблица пользователей из Solana. - * - * Таблица: solana_users - * - * Колонки: - * - login TEXT PRIMARY KEY (COLLATE NOCASE) - * - blockchain_name TEXT NOT NULL - * - solana_key TEXT NOT NULL - * - blockchain_key TEXT NOT NULL - * - device_key TEXT NOT NULL - * - * Правило работы с соединениями: - * - методы с Connection НЕ закрывают соединение - * - методы без Connection сами открывают и закрывают соединение - */ -public final class SolanaUsersDAO { - - private static volatile SolanaUsersDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); - - private SolanaUsersDAO() {} - - public static SolanaUsersDAO getInstance() { - if (instance == null) { - synchronized (SolanaUsersDAO.class) { - if (instance == null) instance = new SolanaUsersDAO(); - } - } - return instance; - } - - // -------------------- INSERT -------------------- - - /** Вставка с внешним соединением. Соединение НЕ закрывает. */ - public void insert(Connection c, SolanaUserEntry user) throws SQLException { - String sql = """ - INSERT INTO solana_users ( - login, blockchain_name, solana_key, blockchain_key, device_key - ) VALUES (?, ?, ?, ?, ?) - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, user.getLogin()); - ps.setString(2, user.getBlockchainName()); - ps.setString(3, user.getSolanaKey()); - ps.setString(4, user.getBlockchainKey()); - ps.setString(5, user.getDeviceKey()); - ps.executeUpdate(); - } - } - - /** Вставка без внешнего соединения. Сам открывает/закрывает. */ - public void insert(SolanaUserEntry user) throws SQLException { - try (Connection c = db.getConnection()) { - insert(c, user); - } - } - - // -------------------- EXISTS -------------------- - - /** Проверка существования по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */ - public boolean existsByLogin(Connection c, String login) throws SQLException { - String sql = """ - SELECT 1 - FROM solana_users - WHERE LOWER(login) = LOWER(?) - LIMIT 1 - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, login); - try (ResultSet rs = ps.executeQuery()) { - return rs.next(); - } - } - } - - /** Проверка существования по login (case-insensitive) без внешнего соединения. Сам открывает/закрывает. */ - public boolean existsByLogin(String login) throws SQLException { - try (Connection c = db.getConnection()) { - return existsByLogin(c, login); - } - } - - /** Проверка существования по blockchain_name (case-sensitive, как в БД) с внешним соединением. */ - public boolean existsByBlockchainName(Connection c, String blockchainName) throws SQLException { - String sql = """ - SELECT 1 - FROM solana_users - WHERE blockchain_name = ? - LIMIT 1 - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, blockchainName); - try (ResultSet rs = ps.executeQuery()) { - return rs.next(); - } - } - } - - /** Проверка существования по blockchain_name без внешнего соединения. */ - public boolean existsByBlockchainName(String blockchainName) throws SQLException { - try (Connection c = db.getConnection()) { - return existsByBlockchainName(c, blockchainName); - } - } - - // -------------------- SELECT -------------------- - - /** Получить по login (case-insensitive) с внешним соединением. Соединение НЕ закрывает. */ - public SolanaUserEntry getByLogin(Connection c, String login) throws SQLException { - String sql = """ - SELECT - login, - blockchain_name, - solana_key, - blockchain_key, - device_key - FROM solana_users - WHERE LOWER(login) = LOWER(?) - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, login); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return mapRow(rs); - } - } - } - - /** Получить по login (case-insensitive) без внешнего соединения. Сам открывает/закрывает. */ - public SolanaUserEntry getByLogin(String login) throws SQLException { - try (Connection c = db.getConnection()) { - return getByLogin(c, login); - } - } - - /** Получить по blockchain_name (case-sensitive) с внешним соединением. Соединение НЕ закрывает. */ - public SolanaUserEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException { - String sql = """ - SELECT - login, - blockchain_name, - solana_key, - blockchain_key, - device_key - FROM solana_users - WHERE blockchain_name = ? - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, blockchainName); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return mapRow(rs); - } - } - } - - /** Получить по blockchain_name без внешнего соединения. */ - public SolanaUserEntry getByBlockchainName(String blockchainName) throws SQLException { - try (Connection c = db.getConnection()) { - return getByBlockchainName(c, blockchainName); - } - } - - /** Поиск по префиксу с внешним соединением. Соединение НЕ закрывает. */ - public List searchByLoginPrefix(Connection c, String prefix) throws SQLException { - String sql = """ - SELECT - login, - blockchain_name, - solana_key, - blockchain_key, - device_key - FROM solana_users - WHERE LOWER(login) LIKE ? - ORDER BY login - LIMIT 5 - """; - - List result = new ArrayList<>(); - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, prefix.toLowerCase() + "%"); - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) result.add(mapRow(rs)); - } - } - - return result; - } - - /** Поиск по префиксу без внешнего соединения. Сам открывает/закрывает. */ - public List searchByLoginPrefix(String prefix) throws SQLException { - try (Connection c = db.getConnection()) { - return searchByLoginPrefix(c, prefix); - } - } - - // -------------------- MAPPER -------------------- - - private SolanaUserEntry mapRow(ResultSet rs) throws SQLException { - SolanaUserEntry e = new SolanaUserEntry(); - - e.setLogin(rs.getString("login")); - e.setBlockchainName(rs.getString("blockchain_name")); - e.setSolanaKey(rs.getString("solana_key")); - e.setBlockchainKey(rs.getString("blockchain_key")); - e.setDeviceKey(rs.getString("device_key")); - - return e; - } -} -package shine.db.dao; - -import shine.db.MsgSubType; -import shine.db.SqliteDbController; - -import java.sql.*; -import java.util.ArrayList; -import java.util.List; - -/** - * SubscriptionsDAO — агрегатный DAO для "каналов" (подписок). - * - * Возвращает по каждой активной подписке (FOLLOW) + "сам на себя": - * - login цели (channelLogin) - * - blockchainName цели (channelBchName) - * - count публикаций (TEXT_NEW) - * - last publication: bytes оригинального блока (для timestamp) - * - last publication: bytes актуального блока (edit или orig) — для текста превью - * - * Важно: - * - это НЕ таблица => сущность результата хранится вложенным классом. - * - методы с Connection НЕ закрывают соединение - * - методы без Connection сами открывают и закрывают соединение - */ -public final class SubscriptionsDAO { - - private static volatile SubscriptionsDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); - - private SubscriptionsDAO() {} - - public static SubscriptionsDAO getInstance() { - if (instance == null) { - synchronized (SubscriptionsDAO.class) { - if (instance == null) instance = new SubscriptionsDAO(); - } - } - return instance; - } - - /** Результат одной строки ("канал") для подписок. */ - public static final class ChannelRow { - - private final String channelLogin; - private final String channelBchName; - - private final int publicationsCount; - - /** Последняя публикация: global number (nullable если публикаций нет). */ - private final Integer lastPublicationGlobalNumber; - - /** Байты оригинальной публикации (FULL bytes блока) — для timestamp (nullable). */ - private final byte[] lastPublicationBlockBytes; - - /** Если публикация редактировалась: global number edit-блока (nullable). */ - private final Integer lastEditGlobalNumber; - - /** Байты edit-блока (FULL bytes блока) (nullable). */ - private final byte[] lastEditBlockBytes; - - public ChannelRow(String channelLogin, - String channelBchName, - int publicationsCount, - Integer lastPublicationGlobalNumber, - byte[] lastPublicationBlockBytes, - Integer lastEditGlobalNumber, - byte[] lastEditBlockBytes) { - - this.channelLogin = channelLogin; - this.channelBchName = channelBchName; - this.publicationsCount = publicationsCount; - this.lastPublicationGlobalNumber = lastPublicationGlobalNumber; - this.lastPublicationBlockBytes = lastPublicationBlockBytes; - this.lastEditGlobalNumber = lastEditGlobalNumber; - this.lastEditBlockBytes = lastEditBlockBytes; - } - - public String getChannelLogin() { return channelLogin; } - public String getChannelBchName() { return channelBchName; } - - public int getPublicationsCount() { return publicationsCount; } - - public Integer getLastPublicationGlobalNumber() { return lastPublicationGlobalNumber; } - public byte[] getLastPublicationBlockBytes() { return lastPublicationBlockBytes; } - - public Integer getLastEditGlobalNumber() { return lastEditGlobalNumber; } - public byte[] getLastEditBlockBytes() { return lastEditBlockBytes; } - } - - // В проекте msg_type=1 означает TEXT (у тебя это уже зафиксировано). - private static final int MSG_TYPE_TEXT = 1; - - /** - * Получить список подписок (активные FOLLOW) + "сам на себя" и по каждой: - * - count публикаций (TEXT_NEW) - * - последнюю публикацию (orig bytes) + её edit (если есть) - * - * Поведение при 0 публикаций: - * - publications_count = 0 - * - last_pub_* = NULL - * - last_edit_* = NULL - */ - public List getSubscribedChannels(Connection c, String requesterLogin) throws SQLException { - - String sql = """ - WITH subs AS ( - -- 1) FOLLOW-каналы - SELECT - cs.to_login AS channel_login, - cs.to_bch_name AS channel_bch_name - FROM connections_state cs - WHERE cs.login = ? - AND cs.rel_type = ? - - UNION - - -- 2) self: все блокчейны пользователя (если их несколько) - SELECT - bs.login AS channel_login, - bs.blockchain_name AS channel_bch_name - FROM blockchain_state bs - WHERE bs.login = ? - ), - pub_counts AS ( - SELECT - b.login AS channel_login, - b.bch_name AS channel_bch_name, - COUNT(*) AS publications_count - FROM blocks b - JOIN subs s - ON s.channel_login = b.login - AND s.channel_bch_name = b.bch_name - WHERE b.msg_type = ? - AND b.msg_sub_type = ? - GROUP BY b.login, b.bch_name - ), - last_pub AS ( - SELECT - b.login AS channel_login, - b.bch_name AS channel_bch_name, - MAX(b.block_global_number) AS last_pub_global_number - FROM blocks b - JOIN subs s - ON s.channel_login = b.login - AND s.channel_bch_name = b.bch_name - WHERE b.msg_type = ? - AND b.msg_sub_type = ? - GROUP BY b.login, b.bch_name - ), - last_pub_block AS ( - SELECT - b.login AS channel_login, - b.bch_name AS channel_bch_name, - b.block_global_number AS last_pub_global_number, - b.block_byte AS last_pub_block_bytes, - b.edited_by_block_global_number AS last_edit_global_number - FROM blocks b - JOIN last_pub lp - ON lp.channel_login = b.login - AND lp.channel_bch_name = b.bch_name - AND lp.last_pub_global_number = b.block_global_number - ), - last_edit_block AS ( - SELECT - e.login AS channel_login, - e.bch_name AS channel_bch_name, - e.block_global_number AS last_edit_global_number, - e.block_byte AS last_edit_block_bytes - FROM blocks e - JOIN last_pub_block p - ON p.channel_login = e.login - AND p.channel_bch_name = e.bch_name - AND p.last_edit_global_number = e.block_global_number - ) - SELECT - s.channel_login, - s.channel_bch_name, - COALESCE(pc.publications_count, 0) AS publications_count, - p.last_pub_global_number, - p.last_pub_block_bytes, - p.last_edit_global_number, - e.last_edit_block_bytes - FROM subs s - LEFT JOIN pub_counts pc - ON pc.channel_login = s.channel_login - AND pc.channel_bch_name = s.channel_bch_name - LEFT JOIN last_pub_block p - ON p.channel_login = s.channel_login - AND p.channel_bch_name = s.channel_bch_name - LEFT JOIN last_edit_block e - ON e.channel_login = s.channel_login - AND e.channel_bch_name = s.channel_bch_name - ORDER BY s.channel_login, s.channel_bch_name - """; - - List out = new ArrayList<>(); - - try (PreparedStatement ps = c.prepareStatement(sql)) { - int i = 1; - - // FOLLOW - ps.setString(i++, requesterLogin); - ps.setInt(i++, (int) MsgSubType.CONNECTION_FOLLOW); - - // self - ps.setString(i++, requesterLogin); - - // pub_counts - ps.setInt(i++, MSG_TYPE_TEXT); - ps.setInt(i++, (int) MsgSubType.TEXT_NEW); - - // last_pub - ps.setInt(i++, MSG_TYPE_TEXT); - ps.setInt(i++, (int) MsgSubType.TEXT_NEW); - - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) { - String channelLogin = rs.getString("channel_login"); - String channelBchName = rs.getString("channel_bch_name"); - - int publicationsCount = rs.getInt("publications_count"); - - Integer lastPubGn = (Integer) rs.getObject("last_pub_global_number"); - byte[] lastPubBytes = rs.getBytes("last_pub_block_bytes"); - - Integer lastEditGn = (Integer) rs.getObject("last_edit_global_number"); - byte[] lastEditBytes = rs.getBytes("last_edit_block_bytes"); - - out.add(new ChannelRow( - channelLogin, - channelBchName, - publicationsCount, - lastPubGn, - lastPubBytes, - lastEditGn, - lastEditBytes - )); - } - } - } - - return out; - } - - /** Вариант без внешнего соединения. Сам открывает/закрывает. */ - public List getSubscribedChannels(String requesterLogin) throws SQLException { - try (Connection c = db.getConnection()) { - return getSubscribedChannels(c, requesterLogin); - } - } -} -package shine.db.dao; - -import shine.db.SqliteDbController; -import shine.db.entities.SolanaUserEntry; - -import java.sql.*; - -/** - * UserCreateDAO — атомарное добавление пользователя: - * - solana_users (login, blockchain_name, solana_key, blockchain_key, device_key) - * - blockchain_state (blockchain_name, login, blockchain_key, size_limit, ... last_block_number=-1 ...) - * - * ВАЖНО: - * - только INSERT (без перезаписи существующих записей) - * - если login или blockchainName заняты — возвращаем false (пользователь уже есть/занято) - */ -public final class UserCreateDAO { - - private static volatile UserCreateDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); - private final SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance(); - - private UserCreateDAO() {} - - public static UserCreateDAO getInstance() { - if (instance == null) { - synchronized (UserCreateDAO.class) { - if (instance == null) instance = new UserCreateDAO(); - } - } - return instance; - } - - /** - * @return true если добавили; false если занято (login уже есть или blockchainName уже существует). - */ - public boolean insertUserWithBlockchain( - String login, - String blockchainName, - String solanaKey, - String blockchainKey, - String deviceKey, - long sizeLimit, - long nowMs - ) throws SQLException { - - try (Connection c = db.getConnection()) { - boolean oldAuto = c.getAutoCommit(); - c.setAutoCommit(false); - - // BEGIN IMMEDIATE — чтобы сразу взять write-lock и не ловить гонки - try (Statement st = c.createStatement()) { - st.execute("BEGIN IMMEDIATE"); - } - - try { - // 1) solana_users - SolanaUserEntry u = new SolanaUserEntry(); - u.setLogin(login); - u.setBlockchainName(blockchainName); - u.setSolanaKey(solanaKey); - u.setBlockchainKey(blockchainKey); - u.setDeviceKey(deviceKey); - - usersDao.insert(c, u); // если login занят (NOCASE) или blockchainName (unique) -> constraint - - // 2) blockchain_state — строго INSERT, без UPSERT (иначе можно перезаписать существующую цепочку) - insertBlockchainStateStrict( - c, - blockchainName, - login, - blockchainKey, - sizeLimit, - nowMs - ); - - c.commit(); - return true; - - } catch (SQLException e) { - c.rollback(); - - String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase(); - if (msg.contains("constraint")) { - return false; - } - throw e; - - } finally { - c.setAutoCommit(oldAuto); - } - } - } - - private static void insertBlockchainStateStrict( - Connection c, - String blockchainName, - String login, - String blockchainKey, - long sizeLimit, - long nowMs - ) throws SQLException { - - String sql = """ - INSERT INTO blockchain_state ( - blockchain_name, - login, - blockchain_key, - size_limit, - file_size_bytes, - last_block_number, - last_block_hash, - updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - int i = 1; - ps.setString(i++, blockchainName); - ps.setString(i++, login); - ps.setString(i++, blockchainKey); - - ps.setLong(i++, sizeLimit); - ps.setLong(i++, 0L); - - ps.setInt(i++, -1); - ps.setNull(i++, Types.BLOB); // старт: блоков ещё нет - ps.setLong(i++, nowMs); - - ps.executeUpdate(); // если blockchainName занят -> constraint (PK) - } - } -} -package shine.db.dao; - -import shine.db.SqliteDbController; -import shine.db.entities.UserParamEntry; - -import java.sql.*; -import java.util.ArrayList; -import java.util.List; - -/** - * UserParamsDAO — хранение сохранённых параметров пользователя. - * - * Правило: - * - методы с Connection НЕ закрывают соединение - * - методы без Connection сами открывают и закрывают соединение - * - * ЛОГИКА time_ms: - * - БД принимает запись только если она "новее" (time_ms строго больше текущего). - * - Реализовано атомарно одним SQL: UPSERT + WHERE users_params.time_ms < excluded.time_ms - */ -public final class UserParamsDAO { - - private static volatile UserParamsDAO instance; - private final SqliteDbController db = SqliteDbController.getInstance(); - - private UserParamsDAO() { } - - public static UserParamsDAO getInstance() { - if (instance == null) { - synchronized (UserParamsDAO.class) { - if (instance == null) instance = new UserParamsDAO(); - } - } - return instance; - } - - // -------------------- UPSERT (IF NEWER) -------------------- - - public int upsertIfNewer(Connection c, UserParamEntry e) throws SQLException { - String sql = """ - INSERT INTO users_params ( - login, - param, - time_ms, - value, - device_key, - signature - ) VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(login, param) - DO UPDATE SET - time_ms = excluded.time_ms, - value = excluded.value, - device_key = excluded.device_key, - signature = excluded.signature - WHERE users_params.time_ms < excluded.time_ms - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, e.getLogin()); - ps.setString(2, e.getParam()); - ps.setLong(3, e.getTimeMs()); - ps.setString(4, e.getValue()); - - if (e.getDeviceKey() != null) ps.setString(5, e.getDeviceKey()); - else ps.setNull(5, Types.VARCHAR); - - if (e.getSignature() != null) ps.setString(6, e.getSignature()); - else ps.setNull(6, Types.VARCHAR); - - return ps.executeUpdate(); - } - } - - public int upsertIfNewer(UserParamEntry e) throws SQLException { - try (Connection c = db.getConnection()) { - return upsertIfNewer(c, e); - } - } - - // -------------------- SELECT -------------------- - - public UserParamEntry getByLoginAndParam(Connection c, String login, String param) throws SQLException { - String sql = """ - SELECT - login, - param, - time_ms, - value, - device_key, - signature - FROM users_params - WHERE login = ? AND param = ? - LIMIT 1 - """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, login); - ps.setString(2, param); - - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return mapRow(rs); - } - } - } - - public UserParamEntry getByLoginAndParam(String login, String param) throws SQLException { - try (Connection c = db.getConnection()) { - return getByLoginAndParam(c, login, param); - } - } - - public List getByLogin(Connection c, String login) throws SQLException { - String sql = """ - SELECT - login, - param, - time_ms, - value, - device_key, - signature - FROM users_params - WHERE login = ? - ORDER BY time_ms DESC - """; - - List list = new ArrayList<>(); - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, login); - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) list.add(mapRow(rs)); - } - } - return list; - } - - public List getByLogin(String login) throws SQLException { - try (Connection c = db.getConnection()) { - return getByLogin(c, login); - } - } - - // -------------------- MAPPER -------------------- - - private static UserParamEntry mapRow(ResultSet rs) throws SQLException { - UserParamEntry e = new UserParamEntry(); - e.setLogin(rs.getString("login")); - e.setParam(rs.getString("param")); - e.setTimeMs(rs.getLong("time_ms")); - e.setValue(rs.getString("value")); - - String dk = rs.getString("device_key"); - if (rs.wasNull()) dk = null; - e.setDeviceKey(dk); - - String sig = rs.getString("signature"); - if (rs.wasNull()) sig = null; - e.setSignature(sig); - - return e; - } -} -package shine.db; - -import utils.config.AppConfig; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.file.*; -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.SQLException; -import java.sql.Statement; - -/** - * DatabaseInitializer — создание новой SQLite-БД по схеме SHiNE. - * - * В этой версии: - * - создаём ТОЛЬКО таблицы/индексы - * - в конце вызываем DatabaseTriggersInstaller.createAllTriggers(st) - * - * v2 (sessions): - * - active_sessions.session_pwd удалён - * - active_sessions.session_key хранит публичный ключ сессии (sessionPubKeyB64) - */ -public final class DatabaseInitializer { - - private DatabaseInitializer() {} - - /* ===================== TEXT (msg_type=1) ===================== */ - - public static final short TEXT_NEW = 1; - public static final short TEXT_REPLY = 2; - public static final short TEXT_REPOST = 3; - public static final short TEXT_EDIT = 10; - - /* ===================== REACTION (msg_type=2) ===================== */ - - public static final short REACTION_LIKE = 1; - - /* ===================== CONNECTION (msg_type=3) ===================== */ - public static final short CONNECTION_FRIEND = 10; - public static final short CONNECTION_UNFRIEND = 11; - - public static final short CONNECTION_CONTACT = 20; - public static final short CONNECTION_UNCONTACT = 21; - - public static final short CONNECTION_FOLLOW = 30; - public static final short CONNECTION_UNFOLLOW = 31; - - public static void createNewDB(String[] args) { - AppConfig config = AppConfig.getInstance(); - String dbPath = config.getParam("db.path"); - - if (dbPath == null || dbPath.isBlank()) { - System.err.println("Параметр db.path не задан в application.properties"); - return; - } - - Path dbFile = Paths.get(dbPath); - try { - Path parent = dbFile.getParent(); - if (parent != null && !Files.exists(parent)) { - Files.createDirectories(parent); - } - - if (Files.exists(dbFile)) { - System.out.println("Файл базы данных уже существует: " + dbFile.toAbsolutePath()); - System.out.print("Пересоздать БД (СТАРАЯ БУДЕТ УДАЛЕНА)? [y/N]: "); - - BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); - String answer = reader.readLine(); - if (!"y".equalsIgnoreCase(answer) && !"yes".equalsIgnoreCase(answer)) { - System.out.println("Операция отменена. БД не изменена."); - return; - } - - Files.delete(dbFile); - System.out.println("Старый файл БД удалён."); - } - - createSchema("jdbc:sqlite:" + dbPath); - System.out.println("Новая БД успешно создана по пути: " + dbFile.toAbsolutePath()); - - } catch (IOException e) { - System.err.println("Ошибка работы с файлом БД: " + e.getMessage()); - } catch (SQLException e) { - System.err.println("Ошибка создания схемы БД: " + e.getMessage()); - } - } - - private static void createSchema(String jdbcUrl) throws SQLException { - try { - Class.forName("org.sqlite.JDBC"); - } catch (ClassNotFoundException e) { - throw new RuntimeException("SQLite JDBC driver not found", e); - } - - try (Connection conn = DriverManager.getConnection(jdbcUrl); - Statement st = conn.createStatement()) { - - st.execute("PRAGMA foreign_keys = ON"); - - // 1. solana_users - // ВАЖНО: - // - Все требуемые поля теперь лежат в solana_users: - // login, blockchain_name, solana_key, blockchain_key, device_key - // - Поиск по login в DAO сделан case-insensitive. - // - Для защиты от дублей "Anya" и "anya" добавляем COLLATE NOCASE на PRIMARY KEY. - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS solana_users ( - login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, - blockchain_name TEXT NOT NULL, - solana_key TEXT NOT NULL, - blockchain_key TEXT NOT NULL, - device_key TEXT NOT NULL - ); - """); - - st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_solana_users_blockchain_name - ON solana_users (blockchain_name); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_solana_users_login - ON solana_users (login); - """); - - // 2. active_sessions (v2) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS active_sessions ( - session_id TEXT NOT NULL PRIMARY KEY, - login TEXT NOT NULL, - session_key TEXT NOT NULL, - storage_pwd TEXT NOT NULL, - session_created_at_ms INTEGER NOT NULL, - last_authirificated_at_ms INTEGER NOT NULL, - push_endpoint TEXT, - push_p256dh_key TEXT, - push_auth_key TEXT, - client_ip TEXT, - client_info_from_client TEXT, - client_info_from_request TEXT, - user_language TEXT, - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_active_sessions_login - ON active_sessions (login); - """); - - // 3. users_params - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS users_params ( - login TEXT NOT NULL, - param TEXT NOT NULL, - time_ms INTEGER NOT NULL, - value TEXT NOT NULL, - device_key TEXT, - signature TEXT, - FOREIGN KEY (login) REFERENCES solana_users(login), - UNIQUE (login, param) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_users_params_login - ON users_params (login); - """); - - // 4. ip_geo_cache - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS ip_geo_cache ( - ip TEXT NOT NULL PRIMARY KEY, - geo TEXT, - updated_at_ms INTEGER NOT NULL - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_ip_geo_cache_updated_at - ON ip_geo_cache (updated_at_ms); - """); - - // 5. blockchain_state - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS blockchain_state ( - blockchain_name TEXT NOT NULL PRIMARY KEY, - login TEXT NOT NULL, - blockchain_key TEXT NOT NULL, - - size_limit INTEGER NOT NULL, - file_size_bytes INTEGER NOT NULL, - - last_block_number INTEGER NOT NULL, - last_block_hash BLOB, - - updated_at_ms INTEGER NOT NULL, - - FOREIGN KEY (login) REFERENCES solana_users(login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_blockchain_state_login - ON blockchain_state (login); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_blockchain_state_updated_at - ON blockchain_state (updated_at_ms); - """); - - // 6. blocks (+ line_code) - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS blocks ( - login TEXT NOT NULL, - bch_name TEXT NOT NULL, - block_number INTEGER NOT NULL CHECK(block_number >= 0), - - msg_type INTEGER NOT NULL, - msg_sub_type INTEGER NOT NULL, - - block_bytes BLOB NOT NULL, - - -- target (reply/like/edit и т.д.) - to_login TEXT, - to_bch_name TEXT, - to_block_number INTEGER CHECK(to_block_number IS NULL OR to_block_number >= 0), - to_block_hash BLOB, - - -- собственные данные - block_hash BLOB NOT NULL, - block_signature BLOB NOT NULL, - - -- если этот блок был изменён последним edit'ом - edited_by_block_number INTEGER CHECK(edited_by_block_number IS NULL OR edited_by_block_number >= 0), - - -- линейность (опционально) - line_code INTEGER CHECK(line_code IS NULL OR line_code >= 0), - prev_line_number INTEGER CHECK(prev_line_number IS NULL OR prev_line_number >= 0), - prev_line_hash BLOB, - this_line_number INTEGER CHECK(this_line_number IS NULL OR this_line_number >= 0), - - FOREIGN KEY (login) REFERENCES solana_users(login), - FOREIGN KEY (bch_name) REFERENCES blockchain_state(blockchain_name), - - UNIQUE (bch_name, block_number) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number - ON blocks (bch_name, block_number); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_blocks_to_target - ON blocks (to_login, to_bch_name, to_block_number); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_blocks_by_line - ON blocks (bch_name, line_code, this_line_number); - """); - - // 7) connections_state - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS connections_state ( - login TEXT NOT NULL, - rel_type INTEGER NOT NULL, - to_login TEXT NOT NULL, - to_bch_name TEXT NOT NULL, - to_block_number INTEGER, - to_block_hash BLOB, - - FOREIGN KEY (login) REFERENCES solana_users(login), - - UNIQUE (login, rel_type, to_login) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_login - ON connections_state (login); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_to_login - ON connections_state (to_login); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_connections_state_pair - ON connections_state (login, to_login); - """); - - // 8) message_stats - st.executeUpdate(""" - CREATE TABLE IF NOT EXISTS message_stats ( - to_login TEXT NOT NULL, - to_bch_name TEXT NOT NULL, - to_block_number INTEGER NOT NULL, - to_block_hash BLOB NOT NULL, - - likes_count INTEGER NOT NULL DEFAULT 0, - replies_count INTEGER NOT NULL DEFAULT 0, - edits_count INTEGER NOT NULL DEFAULT 0, - - UNIQUE ( - to_login, - to_bch_name, - to_block_number, - to_block_hash - ) - ); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_message_stats_target - ON message_stats (to_bch_name, to_block_number, to_block_hash); - """); - - st.executeUpdate(""" - CREATE INDEX IF NOT EXISTS idx_message_stats_login - ON message_stats (to_login); - """); - - DatabaseTriggersInstaller.createAllTriggers(st); - } - } -} -package shine.db; - -import java.sql.SQLException; -import java.sql.Statement; - -/** - * DatabaseTriggersInstaller — устанавливает триггеры, которые поддерживают бизнес-логику БД. - * - * Мы специально сделали триггеры максимально "совместимыми": - * - НЕТ динамических сообщений в RAISE(...): только фиксированные строки. - * (Некоторые SQLite-сборки / просмотрщики падают на "||" внутри RAISE.) - * - НЕТ UPSERT "ON CONFLICT DO UPDATE" — вместо него: - * INSERT OR IGNORE + UPDATE - * (Старые SQLite не знают UPSERT.) - * - * ============================================================================= - * ОПИСАНИЕ ТРИГГЕРОВ - * ============================================================================= - * - * [1] trg_blocks_line_integrity_bi (BEFORE INSERT ON blocks) - * Контроль целостности "линий" (line_code / prev_line_number / prev_line_hash / this_line_number). - * - * Зачем это нужно: - * - В каналах/ветках/действиях ты хочешь иметь "линейную" последовательность, - * где каждый следующий блок явно ссылается на предыдущий блок линии - * и подтверждает, что ссылка не подменена. - * - * Когда срабатывает: - * - ТОЛЬКО если при вставке передано ХОТЯ БЫ ОДНО из line-полей. - * - Если line-поля не переданы — триггер вообще не работает (это важно). - * - * Что проверяет: - * A) line-поля допускаются только для msg_type: - * 0 (TECH), 1 (TEXT), 3 (CONNECTION), 4 (USER_PARAM) - * B) Если пришло хоть одно line-поле — обязаны прийти ВСЕ 4 (никаких "частичных") - * C) prev-блок линии существует в той же цепочке bch_name - * D) prev_hash совпадает с block_hash найденного prev-блока - * E) line_code корректный: - * - либо первый шаг после root: prev_line_number == line_code - * - либо prev уже принадлежит этой линии: p.line_code == NEW.line_code - * F) this_line_number: - * - первый шаг после root: - * TEXT: this_line_number = 0 - * TECH/CONNECTION/USER_PARAM: this_line_number = 1 - * - обычный шаг: - * TEXT: допускаем same или +1 (чтобы "edit" мог не двигать шаг) - * TECH/CONNECTION/USER_PARAM: строго prev.this + 1 - * - * Какие ошибки кидает: - * - LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE - * - LINE_ERR_PARTIAL_FIELDS - * - LINE_ERR_NO_PREV - * - LINE_ERR_PREV_HASH_MISMATCH - * - LINE_ERR_LINE_CODE_MISMATCH - * - LINE_ERR_FIRST_STEP_BAD_THIS - * - LINE_ERR_THIS_LINE_BAD_STEP - * - * [2] trg_blocks_connection_state_ai (AFTER INSERT ON blocks WHEN msg_type=3) - * Поддерживает таблицу connections_state как "текущее состояние" отношений: - * - FRIEND/CONTACT/FOLLOW -> добавить/обновить состояние - * - UNFRIEND/UNCONTACT/UNFOLLOW -> удалить соответствующее "позитивное" состояние - * - * [3] trg_blocks_message_stats_like_ai (AFTER INSERT ON blocks WHEN msg_type=2 AND sub_type=LIKE) - * Поддерживает likes_count в message_stats для цели (to_*). - * - * [4] trg_blocks_message_stats_reply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=REPLY) - * Поддерживает replies_count в message_stats. - * - * [5] trg_blocks_edit_apply_ai (AFTER INSERT ON blocks WHEN msg_type=1 AND sub_type=EDIT) - * Логика edit: - * - помечает исходный блок edited_by_block_number = NEW.block_number - * - увеличивает edits_count в message_stats - */ -public final class DatabaseTriggersInstaller { - - private DatabaseTriggersInstaller() {} - - public static void createAllTriggers(Statement st) throws SQLException { - // На всякий случай убираем старые "криво названные" триггеры, - // если они когда-то попадали в БД. - st.executeUpdate("DROP TRIGGER IF EXISTS trg_block_lini_integriti_by;"); - st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_line_integrity_bi;"); - - st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_connection_state_ai;"); - st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_like_ai;"); - st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_message_stats_reply_ai;"); - st.executeUpdate("DROP TRIGGER IF EXISTS trg_blocks_edit_apply_ai;"); - - createLineIntegrityTrigger(st); - createConnectionStateTrigger(st); - createMessageStatsLikeTrigger(st); - createMessageStatsReplyTrigger(st); - createEditApplyTrigger(st); - } - - private static void createLineIntegrityTrigger(Statement st) throws SQLException { - st.executeUpdate(""" - CREATE TRIGGER IF NOT EXISTS trg_blocks_line_integrity_bi - BEFORE INSERT ON blocks - WHEN - NEW.line_code IS NOT NULL - OR NEW.prev_line_number IS NOT NULL - OR NEW.prev_line_hash IS NOT NULL - OR NEW.this_line_number IS NOT NULL - BEGIN - SELECT RAISE(ABORT, 'LINE_ERR_UNSUPPORTED_TYPE_WITH_LINE') - WHERE NOT (NEW.msg_type IN (0, 1, 3, 4)); - - SELECT RAISE(ABORT, 'LINE_ERR_PARTIAL_FIELDS') - WHERE NEW.line_code IS NULL - OR NEW.prev_line_number IS NULL - OR NEW.prev_line_hash IS NULL - OR NEW.this_line_number IS NULL; - - SELECT RAISE(ABORT, 'LINE_ERR_NO_PREV') - WHERE NOT EXISTS( - SELECT 1 - FROM blocks p - WHERE p.bch_name = NEW.bch_name - AND p.block_number = NEW.prev_line_number - LIMIT 1 - ); - - SELECT RAISE(ABORT, 'LINE_ERR_PREV_HASH_MISMATCH') - WHERE NOT EXISTS( - SELECT 1 - FROM blocks p - WHERE p.bch_name = NEW.bch_name - AND p.block_number = NEW.prev_line_number - AND p.block_hash = NEW.prev_line_hash - LIMIT 1 - ); - - SELECT RAISE(ABORT, 'LINE_ERR_LINE_CODE_MISMATCH') - WHERE NEW.prev_line_number <> NEW.line_code - AND NOT EXISTS( - SELECT 1 - FROM blocks p - WHERE p.bch_name = NEW.bch_name - AND p.block_number = NEW.prev_line_number - AND p.line_code = NEW.line_code - LIMIT 1 - ); - - SELECT RAISE(ABORT, 'LINE_ERR_FIRST_STEP_BAD_THIS') - WHERE NEW.prev_line_number = NEW.line_code - AND NEW.this_line_number <> (CASE WHEN NEW.msg_type = 1 THEN 0 ELSE 1 END); - - SELECT RAISE(ABORT, 'LINE_ERR_THIS_LINE_BAD_STEP') - WHERE NEW.prev_line_number <> NEW.line_code - AND NOT EXISTS( - SELECT 1 - FROM blocks p - WHERE p.bch_name = NEW.bch_name - AND p.block_number = NEW.prev_line_number - AND p.this_line_number IS NOT NULL - AND ( - (NEW.msg_type = 1 AND - (NEW.this_line_number = p.this_line_number OR NEW.this_line_number = p.this_line_number + 1) - ) - OR - (NEW.msg_type IN (0,3,4) AND NEW.this_line_number = p.this_line_number + 1) - ) - LIMIT 1 - ); - END; - """); - } - - private static void createConnectionStateTrigger(Statement st) throws SQLException { - int FRIEND = (int) DatabaseInitializer.CONNECTION_FRIEND; - int CONTACT = (int) DatabaseInitializer.CONNECTION_CONTACT; - int FOLLOW = (int) DatabaseInitializer.CONNECTION_FOLLOW; - - int UNFRIEND = (int) DatabaseInitializer.CONNECTION_UNFRIEND; - int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT; - int UNFOLLOW = (int) DatabaseInitializer.CONNECTION_UNFOLLOW; - - st.executeUpdate(""" - CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai - AFTER INSERT ON blocks - WHEN NEW.msg_type = 3 - BEGIN - -- FRIEND/CONTACT/FOLLOW: - -- 1) если записи нет — создаём - INSERT OR IGNORE INTO connections_state ( - login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash - ) - SELECT - NEW.login, - NEW.msg_sub_type, - NEW.to_login, - NEW.to_bch_name, - NEW.to_block_number, - NEW.to_block_hash - WHERE NEW.msg_sub_type IN (%d, %d, %d) - AND NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL; - - -- 2) если запись есть — обновляем актуальные to_* - UPDATE connections_state - SET - to_bch_name = NEW.to_bch_name, - to_block_number = NEW.to_block_number, - to_block_hash = NEW.to_block_hash - WHERE login = NEW.login - AND rel_type = NEW.msg_sub_type - AND to_login = NEW.to_login - AND NEW.msg_sub_type IN (%d, %d, %d) - AND NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL; - - -- UNFRIEND/UNCONTACT/UNFOLLOW: - -- удаляем соответствующее "позитивное" состояние - DELETE FROM connections_state - WHERE login = NEW.login - AND to_login = NEW.to_login - AND rel_type = CASE NEW.msg_sub_type - WHEN %d THEN %d - WHEN %d THEN %d - WHEN %d THEN %d - ELSE rel_type - END - AND NEW.msg_sub_type IN (%d, %d, %d); - END; - """.formatted( - FRIEND, CONTACT, FOLLOW, - FRIEND, CONTACT, FOLLOW, - - UNFRIEND, FRIEND, - UNCONTACT, CONTACT, - UNFOLLOW, FOLLOW, - - UNFRIEND, UNCONTACT, UNFOLLOW - )); - } - - private static void createMessageStatsLikeTrigger(Statement st) throws SQLException { - int LIKE = (int) DatabaseInitializer.REACTION_LIKE; - - st.executeUpdate(""" - CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_like_ai - AFTER INSERT ON blocks - WHEN NEW.msg_type = 2 AND NEW.msg_sub_type = %d - BEGIN - -- создаём строку, если её не было - INSERT OR IGNORE INTO message_stats ( - to_login, to_bch_name, to_block_number, to_block_hash, - likes_count, replies_count, edits_count - ) - SELECT - NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, - 0, 0, 0 - WHERE NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - - -- +1 like - UPDATE message_stats - SET likes_count = likes_count + 1 - WHERE to_login = NEW.to_login - AND to_bch_name = NEW.to_bch_name - AND to_block_number = NEW.to_block_number - AND to_block_hash = NEW.to_block_hash - AND NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - END; - """.formatted(LIKE)); - } - - private static void createMessageStatsReplyTrigger(Statement st) throws SQLException { - int REPLY = (int) DatabaseInitializer.TEXT_REPLY; - - st.executeUpdate(""" - CREATE TRIGGER IF NOT EXISTS trg_blocks_message_stats_reply_ai - AFTER INSERT ON blocks - WHEN NEW.msg_type = 1 AND NEW.msg_sub_type = %d - BEGIN - INSERT OR IGNORE INTO message_stats ( - to_login, to_bch_name, to_block_number, to_block_hash, - likes_count, replies_count, edits_count - ) - SELECT - NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, - 0, 0, 0 - WHERE NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - - UPDATE message_stats - SET replies_count = replies_count + 1 - WHERE to_login = NEW.to_login - AND to_bch_name = NEW.to_bch_name - AND to_block_number = NEW.to_block_number - AND to_block_hash = NEW.to_block_hash - AND NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - END; - """.formatted(REPLY)); - } - - private static void createEditApplyTrigger(Statement st) throws SQLException { - int EDIT = (int) DatabaseInitializer.TEXT_EDIT; - - st.executeUpdate(""" - CREATE TRIGGER IF NOT EXISTS trg_blocks_edit_apply_ai - AFTER INSERT ON blocks - WHEN NEW.msg_type = 1 AND NEW.msg_sub_type = %d - BEGIN - -- 1) помечаем исходный блок, что его "перекрыл" этот edit - UPDATE blocks - SET edited_by_block_number = NEW.block_number - WHERE login = NEW.login - AND bch_name = NEW.bch_name - AND block_number = NEW.to_block_number - AND NEW.to_block_number IS NOT NULL; - - -- 2) создаём stats-строку если её не было - INSERT OR IGNORE INTO message_stats ( - to_login, to_bch_name, to_block_number, to_block_hash, - likes_count, replies_count, edits_count - ) - SELECT - NEW.to_login, NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash, - 0, 0, 0 - WHERE NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - - -- 3) +1 edit - UPDATE message_stats - SET edits_count = edits_count + 1 - WHERE to_login = NEW.to_login - AND to_bch_name = NEW.to_bch_name - AND to_block_number = NEW.to_block_number - AND to_block_hash = NEW.to_block_hash - AND NEW.to_login IS NOT NULL - AND NEW.to_bch_name IS NOT NULL - AND NEW.to_block_number IS NOT NULL - AND NEW.to_block_hash IS NOT NULL; - END; - """.formatted(EDIT)); - } -} -package shine.db.entities; - -/** - * Модель активной сессии (таблица active_sessions). - */ -public class ActiveSessionEntry { - - private String sessionId; - private String login; - - /** session_key: публичный ключ сессии (base64 от 32 байт). */ - private String sessionKey; - - private String storagePwd; - private long sessionCreatedAtMs; - private long lastAuthirificatedAtMs; - - private String pushEndpoint; - private String pushP256dhKey; - private String pushAuthKey; - - private String clientIp; - private String clientInfoFromClient; - private String clientInfoFromRequest; - private String userLanguage; - - public ActiveSessionEntry() { } - - public ActiveSessionEntry(String sessionId, - String login, - String sessionKey, - String storagePwd, - long sessionCreatedAtMs, - long lastAuthirificatedAtMs, - String pushEndpoint, - String pushP256dhKey, - String pushAuthKey, - String clientIp, - String clientInfoFromClient, - String clientInfoFromRequest, - String userLanguage) { - this.sessionId = sessionId; - this.login = login; - this.sessionKey = sessionKey; - this.storagePwd = storagePwd; - this.sessionCreatedAtMs = sessionCreatedAtMs; - this.lastAuthirificatedAtMs = lastAuthirificatedAtMs; - this.pushEndpoint = pushEndpoint; - this.pushP256dhKey = pushP256dhKey; - this.pushAuthKey = pushAuthKey; - this.clientIp = clientIp; - this.clientInfoFromClient = clientInfoFromClient; - this.clientInfoFromRequest = clientInfoFromRequest; - this.userLanguage = userLanguage; - } - - public String getSessionId() { return sessionId; } - public void setSessionId(String sessionId) { this.sessionId = sessionId; } - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getSessionKey() { return sessionKey; } - public void setSessionKey(String sessionKey) { this.sessionKey = sessionKey; } - - public String getStoragePwd() { return storagePwd; } - public void setStoragePwd(String storagePwd) { this.storagePwd = storagePwd; } - - public long getSessionCreatedAtMs() { return sessionCreatedAtMs; } - public void setSessionCreatedAtMs(long sessionCreatedAtMs) { this.sessionCreatedAtMs = sessionCreatedAtMs; } - - public long getLastAuthirificatedAtMs() { return lastAuthirificatedAtMs; } - public void setLastAuthirificatedAtMs(long lastAuthirificatedAtMs) { this.lastAuthirificatedAtMs = lastAuthirificatedAtMs; } - - public String getPushEndpoint() { return pushEndpoint; } - public void setPushEndpoint(String pushEndpoint) { this.pushEndpoint = pushEndpoint; } - - public String getPushP256dhKey() { return pushP256dhKey; } - public void setPushP256dhKey(String pushP256dhKey) { this.pushP256dhKey = pushP256dhKey; } - - public String getPushAuthKey() { return pushAuthKey; } - public void setPushAuthKey(String pushAuthKey) { this.pushAuthKey = pushAuthKey; } - - public String getClientIp() { return clientIp; } - public void setClientIp(String clientIp) { this.clientIp = clientIp; } - - public String getClientInfoFromClient() { return clientInfoFromClient; } - public void setClientInfoFromClient(String clientInfoFromClient) { this.clientInfoFromClient = clientInfoFromClient; } - - public String getClientInfoFromRequest() { return clientInfoFromRequest; } - public void setClientInfoFromRequest(String clientInfoFromRequest) { this.clientInfoFromRequest = clientInfoFromRequest; } - - public String getUserLanguage() { return userLanguage; } - public void setUserLanguage(String userLanguage) { this.userLanguage = userLanguage; } -} -package shine.db.entities; - -import java.util.Base64; - -/** - * Агрегатная сущность текущего состояния блокчейна. - * - * ВАЖНО: - * - Убраны все поля линий line0..7 (они больше не нужны). - * - Оставляем: - * last_block_number - * last_block_hash - * - * Остальные поля (login, blockchain_key, лимиты) оставлены как в проекте, - * потому что серверу они реально нужны (ключ подписи/лимит файла). - */ -public final class BlockchainStateEntry { - - private String blockchainName; - private String login; - - private String blockchainKey; // Base64(32) - - private long sizeLimit; - private long fileSizeBytes; - - private int lastBlockNumber; // было last_global_number - private byte[] lastBlockHash; // было last_global_hash (nullable) - - private long updatedAtMs; - - public BlockchainStateEntry() {} - - public String getBlockchainName() { return blockchainName; } - public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getBlockchainKey() { return blockchainKey; } - public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } - - public byte[] getBlockchainKeyBytes() { - if (blockchainKey == null) return null; - String s = blockchainKey.trim(); - if (s.isEmpty()) return null; - try { - byte[] b = Base64.getDecoder().decode(s); - return (b != null && b.length == 32) ? b : null; - } catch (IllegalArgumentException e) { - return null; - } - } - - public long getSizeLimit() { return sizeLimit; } - public void setSizeLimit(long sizeLimit) { this.sizeLimit = sizeLimit; } - - public long getFileSizeBytes() { return fileSizeBytes; } - public void setFileSizeBytes(long fileSizeBytes) { this.fileSizeBytes = fileSizeBytes; } - - public int getLastBlockNumber() { return lastBlockNumber; } - public void setLastBlockNumber(int lastBlockNumber) { this.lastBlockNumber = lastBlockNumber; } - - public byte[] getLastBlockHash() { return lastBlockHash; } - public void setLastBlockHash(byte[] lastBlockHash) { this.lastBlockHash = lastBlockHash; } - - public long getUpdatedAtMs() { return updatedAtMs; } - public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; } -} -package shine.db.entities; - -/** - * Запись блока (таблица blocks) — обновлённая модель под новый формат. - * - * Храним: - * - login, bch_name (как было в проекте, чтобы не ломать общую БД) - * - block_number (глобальный номер в этой цепочке) - * - block_bytes (полный блок: preimage + signature) - * - block_hash (32 байта вычисленный SHA-256(preimage)) - * - block_signature (64 байта) - * - * Опционально: - * - line_code / prev_line_number / prev_line_hash / this_line_number - * - * Плюс поля индексации: - * - msg_type / msg_sub_type - * - to_* (если есть target) - * - edited_by_block_number (для TEXT_EDIT) - */ -public class BlockEntry { - - private String login; - private String bchName; - - private int blockNumber; - - private int msgType; - private int msgSubType; - - private byte[] blockBytes; - - private String toLogin; - private String toBchName; - private Integer toBlockNumber; - private byte[] toBlockHash; - - private byte[] blockHash; - private byte[] blockSignature; - - private Integer editedByBlockNumber; - - // NEW: - private Integer lineCode; - - private Integer prevLineNumber; - private byte[] prevLineHash; - private Integer thisLineNumber; - - public BlockEntry() {} - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getBchName() { return bchName; } - public void setBchName(String bchName) { this.bchName = bchName; } - - public int getBlockNumber() { return blockNumber; } - public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; } - - public int getMsgType() { return msgType; } - public void setMsgType(int msgType) { this.msgType = msgType; } - - public int getMsgSubType() { return msgSubType; } - public void setMsgSubType(int msgSubType) { this.msgSubType = msgSubType; } - - public byte[] getBlockBytes() { return blockBytes; } - public void setBlockBytes(byte[] blockBytes) { this.blockBytes = blockBytes; } - - public String getToLogin() { return toLogin; } - public void setToLogin(String toLogin) { this.toLogin = toLogin; } - - public String getToBchName() { return toBchName; } - public void setToBchName(String toBchName) { this.toBchName = toBchName; } - - public Integer getToBlockNumber() { return toBlockNumber; } - public void setToBlockNumber(Integer toBlockNumber) { this.toBlockNumber = toBlockNumber; } - - public byte[] getToBlockHash() { return toBlockHash; } - public void setToBlockHash(byte[] toBlockHash) { this.toBlockHash = toBlockHash; } - - public byte[] getBlockHash() { return blockHash; } - public void setBlockHash(byte[] blockHash) { this.blockHash = blockHash; } - - public byte[] getBlockSignature() { return blockSignature; } - public void setBlockSignature(byte[] blockSignature) { this.blockSignature = blockSignature; } - - public Integer getEditedByBlockNumber() { return editedByBlockNumber; } - public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = editedByBlockNumber; } - - // NEW: - public Integer getLineCode() { return lineCode; } - public void setLineCode(Integer lineCode) { this.lineCode = lineCode; } - - public Integer getPrevLineNumber() { return prevLineNumber; } - public void setPrevLineNumber(Integer prevLineNumber) { this.prevLineNumber = prevLineNumber; } - - public byte[] getPrevLineHash() { return prevLineHash; } - public void setPrevLineHash(byte[] prevLineHash) { this.prevLineHash = prevLineHash; } - - public Integer getThisLineNumber() { return thisLineNumber; } - public void setThisLineNumber(Integer thisLineNumber) { this.thisLineNumber = thisLineNumber; } -} -package shine.db.entities; - -/** - * Запись в таблице ip_geo_cache. - */ -public class IpGeoCacheEntry { - - private String ip; - private String geo; - private long updatedAtMs; - - public IpGeoCacheEntry() { - } - - public IpGeoCacheEntry(String ip, String geo, long updatedAtMs) { - this.ip = ip; - this.geo = geo; - this.updatedAtMs = updatedAtMs; - } - - public String getIp() { - return ip; - } - - public void setIp(String ip) { - this.ip = ip; - } - - public String getGeo() { - return geo; - } - - public void setGeo(String geo) { - this.geo = geo; - } - - public long getUpdatedAtMs() { - return updatedAtMs; - } - - public void setUpdatedAtMs(long updatedAtMs) { - this.updatedAtMs = updatedAtMs; - } -} -package shine.db.entities; - -import java.util.Base64; - -/** - * SolanaUserEntry — локальная запись пользователя из Solana. - * - * Таблица: solana_users - * - * Поля: - * - login — PRIMARY KEY (TEXT) (case-insensitive на уровне COLLATE NOCASE) - * - blockchain_name — TEXT NOT NULL - * - solana_key — TEXT NOT NULL - * - blockchain_key — TEXT NOT NULL - * - device_key — TEXT NOT NULL - */ -public class SolanaUserEntry { - - private String login; - - private String blockchainName; - - /** Ключ пользователя Solana (публичный ключ логина) */ - private String solanaKey; - - /** Ключ блокчейна (публичный ключ блокчейна) */ - private String blockchainKey; - - /** Ключ устройства (публичный ключ устройства) */ - private String deviceKey; - - public SolanaUserEntry() {} - - public SolanaUserEntry(String login, - String blockchainName, - String solanaKey, - String blockchainKey, - String deviceKey) { - this.login = login; - this.blockchainName = blockchainName; - this.solanaKey = solanaKey; - this.blockchainKey = blockchainKey; - this.deviceKey = deviceKey; - } - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getBlockchainName() { return blockchainName; } - public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } - - public String getSolanaKey() { return solanaKey; } - public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; } - - public String getBlockchainKey() { return blockchainKey; } - public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } - - public String getDeviceKey() { return deviceKey; } - public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } - - // оставляю этот метод как утилиту (иногда удобно), но он работает только для deviceKey: - public byte[] getDeviceKeyByte() { - if (deviceKey == null) return null; - String s = deviceKey.trim(); - if (s.isEmpty()) return null; - - try { - byte[] b = Base64.getDecoder().decode(s); - if (b != null && b.length == 32) return b; - } catch (IllegalArgumentException ignore) {} - - if (s.length() == 64 && s.matches("^[0-9a-fA-F]+$")) { - byte[] out = new byte[32]; - for (int i = 0; i < 32; i++) { - int hi = Character.digit(s.charAt(i * 2), 16); - int lo = Character.digit(s.charAt(i * 2 + 1), 16); - out[i] = (byte) ((hi << 4) | lo); - } - return out; - } - - return null; - } -} -package shine.db.entities; - -/** - * UserParamEntry — сохранённый параметр пользователя. - * - * Таблица: users_params - * - login TEXT NOT NULL - * - param TEXT NOT NULL - * - time_ms INTEGER NOT NULL - * - value TEXT NOT NULL - * - device_key TEXT NULL - * - signature TEXT NULL - */ -public class UserParamEntry { - - private String login; - private String param; - private long timeMs; - private String value; - - private String deviceKey; - private String signature; - - public UserParamEntry() {} - - public UserParamEntry(String login, String param, long timeMs, String value, String deviceKey, String signature) { - this.login = login; - this.param = param; - this.timeMs = timeMs; - this.value = value; - this.deviceKey = deviceKey; - this.signature = signature; - } - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } - - public long getTimeMs() { return timeMs; } - public void setTimeMs(long timeMs) { this.timeMs = timeMs; } - - public String getValue() { return value; } - public void setValue(String value) { this.value = value; } - - public String getDeviceKey() { return deviceKey; } - public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } -} -package shine.db; - -/** - * MsgSubType — единое место для ВСЕХ subType сообщений (msg_sub_type). - * - * ВАЖНО: - * - Значения должны совпадать с body-классами (TextBody/ReactionBody/ConnectionBody/UserParamBody/HeaderBody). - * - После релиза менять числа нельзя (иначе ломается совместимость данных). - */ -public final class MsgSubType { - - private MsgSubType() {} - - /* ===================== HEADER (msg_type=0) ===================== */ - - /** HeaderBody: subType всегда 0 (compat). */ - public static final short HEADER_COMPAT = 0; - - /* ===================== TEXT (msg_type=1) ===================== */ - - /** Новая публикация. */ - public static final short TEXT_NEW = 1; - - /** Ответ (reply). */ - public static final short TEXT_REPLY = 2; - - /** Репост (repost). */ - public static final short TEXT_REPOST = 3; - - /** Редактирование (edit). */ - public static final short TEXT_EDIT = 10; - - /* ===================== REACTION (msg_type=2) ===================== */ - - /** Лайк (LIKE). */ - public static final short REACTION_LIKE = 1; - - /* ===================== CONNECTION (msg_type=3) ===================== */ - /** - * Совпадает с ConnectionBody: - * SET: FRIEND=10, CONTACT=20, FOLLOW=30 - * UNSET: UNFRIEND=11, UNCONTACT=21, UNFOLLOW=31 - */ - - /** Добавить в друзья. */ - public static final short CONNECTION_FRIEND = 10; - - /** Удалить из друзей. */ - public static final short CONNECTION_UNFRIEND = 11; - - /** Добавить в контакты. */ - public static final short CONNECTION_CONTACT = 20; - - /** Удалить из контактов. */ - public static final short CONNECTION_UNCONTACT = 21; - - /** Подписаться (follow). */ - public static final short CONNECTION_FOLLOW = 30; - - /** Отписаться (unfollow). */ - public static final short CONNECTION_UNFOLLOW = 31; - - /* ===================== USER_PARAM (msg_type=4) ===================== */ - - /** Параметр профиля key/value (обе строки). */ - public static final short USER_PARAM_TEXT_TEXT = 1; - - /* ===================== РЕЗЕРВ НА БУДУЩЕЕ ===================== */ - // Если позже захочешь BLOCK/UNBLOCK — лучше добавить НОВЫЕ значения, - // не трогая 10/20/30 и 11/21/31 (например, 40/41). - // public static final short CONNECTION_BLOCK = 40; - // public static final short CONNECTION_UNBLOCK = 41; -} -package shine.db; - -import utils.config.AppConfig; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.SQLException; -import java.sql.Statement; - -public final class SqliteDbController { - - private static volatile SqliteDbController instance; - - private final String jdbcUrl; - - private SqliteDbController() { - try { - Class.forName("org.sqlite.JDBC"); - } catch (ClassNotFoundException e) { - throw new RuntimeException("SQLite JDBC driver not found", e); - } - - String dbPath = AppConfig.getInstance().getParam("db.path"); - if (dbPath == null || dbPath.isBlank()) { - throw new RuntimeException("Config param 'db.path' is not set in application.properties"); - } - - Path dbFile = Paths.get(dbPath); - - if (!Files.exists(dbFile)) { - System.out.println("[DB] Файл БД не найден: " + dbFile.toAbsolutePath()); - System.out.println("[DB] Создаём новую БД с помощью DatabaseInitializer..."); - DatabaseInitializer.createNewDB(new String[0]); - } - - this.jdbcUrl = "jdbc:sqlite:" + dbPath; - } - - public static SqliteDbController getInstance() { - if (instance == null) { - synchronized (SqliteDbController.class) { - if (instance == null) { - instance = new SqliteDbController(); - } - } - } - return instance; - } - - public Connection getConnection() throws SQLException { - Connection conn = DriverManager.getConnection(jdbcUrl); - conn.setAutoCommit(true); - - try (Statement st = conn.createStatement()) { - st.execute("PRAGMA foreign_keys = ON"); - st.execute("PRAGMA journal_mode = WAL"); - st.execute("PRAGMA synchronous = NORMAL"); - st.execute("PRAGMA busy_timeout = 5000"); - } - - return conn; - } - - public void close() { - // no-op - } -} diff --git a/SHiNE-server/shine-server-db/concat_to_file.sh b/SHiNE-server/shine-server-db/concat_to_file.sh deleted file mode 100755 index f6db1f1..0000000 --- a/SHiNE-server/shine-server-db/concat_to_file.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# собрать только *.java файлы и вывести их содержимое в файл -find . -type f -name "*.java" | sort | while read -r f; do - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Все .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" 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 2a8ef89..a1d781e 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 @@ -140,7 +140,7 @@ public final class DatabaseInitializer { // 1. solana_users // ВАЖНО: // - Все требуемые поля теперь лежат в solana_users: - // login, blockchain_name, solana_key, blockchain_key, device_key + // login, blockchain_name, solana_key, blockchain_key, client_key // - Поиск по login в DAO сделан case-insensitive. // - Для защиты от дублей "Anya" и "anya" добавляем COLLATE NOCASE на PRIMARY KEY. st.executeUpdate(""" @@ -149,7 +149,7 @@ public final class DatabaseInitializer { blockchain_name TEXT NOT NULL, solana_key TEXT NOT NULL, blockchain_key TEXT NOT NULL, - device_key TEXT NOT NULL + client_key TEXT NOT NULL ); """); @@ -238,7 +238,7 @@ public final class DatabaseInitializer { param TEXT NOT NULL, time_ms INTEGER NOT NULL, value TEXT NOT NULL, - device_key TEXT, + client_key TEXT, signature TEXT, FOREIGN KEY (login) REFERENCES solana_users(login), UNIQUE (login, param) diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUsersDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUsersDAO.java index 3474a98..c9db7db 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUsersDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUsersDAO.java @@ -17,7 +17,7 @@ import java.util.List; * - blockchain_name TEXT NOT NULL * - solana_key TEXT NOT NULL * - blockchain_key TEXT NOT NULL - * - device_key TEXT NOT NULL + * - client_key TEXT NOT NULL * * Правило работы с соединениями: * - методы с Connection НЕ закрывают соединение @@ -45,7 +45,7 @@ public final class SolanaUsersDAO { public void insert(Connection c, SolanaUserEntry user) throws SQLException { String sql = """ INSERT INTO solana_users ( - login, blockchain_name, solana_key, blockchain_key, device_key + login, blockchain_name, solana_key, blockchain_key, client_key ) VALUES (?, ?, ?, ?, ?) """; @@ -54,7 +54,7 @@ public final class SolanaUsersDAO { ps.setString(2, user.getBlockchainName()); ps.setString(3, user.getSolanaKey()); ps.setString(4, user.getBlockchainKey()); - ps.setString(5, user.getDeviceKey()); + ps.setString(5, user.getClientKey()); ps.executeUpdate(); } } @@ -126,7 +126,7 @@ public final class SolanaUsersDAO { blockchain_name, solana_key, blockchain_key, - device_key + client_key FROM solana_users WHERE LOWER(login) = LOWER(?) """; @@ -155,7 +155,7 @@ public final class SolanaUsersDAO { blockchain_name, solana_key, blockchain_key, - device_key + client_key FROM solana_users WHERE blockchain_name = ? """; @@ -184,7 +184,7 @@ public final class SolanaUsersDAO { blockchain_name, solana_key, blockchain_key, - device_key + client_key FROM solana_users WHERE LOWER(login) LIKE ? ORDER BY login @@ -219,7 +219,7 @@ public final class SolanaUsersDAO { e.setBlockchainName(rs.getString("blockchain_name")); e.setSolanaKey(rs.getString("solana_key")); e.setBlockchainKey(rs.getString("blockchain_key")); - e.setDeviceKey(rs.getString("device_key")); + e.setClientKey(rs.getString("client_key")); return e; } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserCreateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserCreateDAO.java index 431790b..8476f3a 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserCreateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserCreateDAO.java @@ -7,7 +7,7 @@ import java.sql.*; /** * UserCreateDAO — атомарное добавление пользователя: - * - solana_users (login, blockchain_name, solana_key, blockchain_key, device_key) + * - solana_users (login, blockchain_name, solana_key, blockchain_key, client_key) * - blockchain_state (blockchain_name, login, blockchain_key, size_limit, ... last_block_number=-1 ...) * * ВАЖНО: @@ -39,7 +39,7 @@ public final class UserCreateDAO { String blockchainName, String solanaKey, String blockchainKey, - String deviceKey, + String clientKey, long sizeLimit, long nowMs ) throws SQLException { @@ -55,7 +55,7 @@ public final class UserCreateDAO { u.setBlockchainName(blockchainName); u.setSolanaKey(solanaKey); u.setBlockchainKey(blockchainKey); - u.setDeviceKey(deviceKey); + u.setClientKey(clientKey); usersDao.insert(c, u); // если login занят (NOCASE) или blockchainName (unique) -> constraint diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java index 0cb87f2..fe20aeb 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserParamsDAO.java @@ -43,14 +43,14 @@ public final class UserParamsDAO { param, time_ms, value, - device_key, + client_key, signature ) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(login, param) DO UPDATE SET time_ms = excluded.time_ms, value = excluded.value, - device_key = excluded.device_key, + client_key = excluded.client_key, signature = excluded.signature WHERE users_params.time_ms < excluded.time_ms """; @@ -61,7 +61,7 @@ public final class UserParamsDAO { ps.setLong(3, e.getTimeMs()); ps.setString(4, e.getValue()); - if (e.getDeviceKey() != null) ps.setString(5, e.getDeviceKey()); + if (e.getClientKey() != null) ps.setString(5, e.getClientKey()); else ps.setNull(5, Types.VARCHAR); if (e.getSignature() != null) ps.setString(6, e.getSignature()); @@ -86,7 +86,7 @@ public final class UserParamsDAO { param, time_ms, value, - device_key, + client_key, signature FROM users_params WHERE login = ? COLLATE NOCASE AND param = ? @@ -117,7 +117,7 @@ public final class UserParamsDAO { param, time_ms, value, - device_key, + client_key, signature FROM users_params WHERE login = ? COLLATE NOCASE @@ -149,9 +149,9 @@ public final class UserParamsDAO { e.setTimeMs(rs.getLong("time_ms")); e.setValue(rs.getString("value")); - String dk = rs.getString("device_key"); + String dk = rs.getString("client_key"); if (rs.wasNull()) dk = null; - e.setDeviceKey(dk); + e.setClientKey(dk); String sig = rs.getString("signature"); if (rs.wasNull()) sig = null; diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SolanaUserEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SolanaUserEntry.java index b7dbd7c..7552faf 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SolanaUserEntry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SolanaUserEntry.java @@ -12,7 +12,7 @@ import java.util.Base64; * - blockchain_name — TEXT NOT NULL * - solana_key — TEXT NOT NULL * - blockchain_key — TEXT NOT NULL - * - device_key — TEXT NOT NULL + * - client_key — TEXT NOT NULL */ public class SolanaUserEntry { @@ -27,7 +27,7 @@ public class SolanaUserEntry { private String blockchainKey; /** Ключ устройства (публичный ключ устройства) */ - private String deviceKey; + private String clientKey; public SolanaUserEntry() {} @@ -35,12 +35,12 @@ public class SolanaUserEntry { String blockchainName, String solanaKey, String blockchainKey, - String deviceKey) { + String clientKey) { this.login = login; this.blockchainName = blockchainName; this.solanaKey = solanaKey; this.blockchainKey = blockchainKey; - this.deviceKey = deviceKey; + this.clientKey = clientKey; } public String getLogin() { return login; } @@ -55,13 +55,13 @@ public class SolanaUserEntry { public String getBlockchainKey() { return blockchainKey; } public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } - public String getDeviceKey() { return deviceKey; } - public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } + public String getClientKey() { return clientKey; } + public void setClientKey(String clientKey) { this.clientKey = clientKey; } - // оставляю этот метод как утилиту (иногда удобно), но он работает только для deviceKey: - public byte[] getDeviceKeyByte() { - if (deviceKey == null) return null; - String s = deviceKey.trim(); + // оставляю этот метод как утилиту (иногда удобно), но он работает только для clientKey: + public byte[] getClientKeyByte() { + if (clientKey == null) return null; + String s = clientKey.trim(); if (s.isEmpty()) return null; try { diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserParamEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserParamEntry.java index 656614a..55fa207 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserParamEntry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/UserParamEntry.java @@ -8,7 +8,7 @@ package shine.db.entities; * - param TEXT NOT NULL * - time_ms INTEGER NOT NULL * - value TEXT NOT NULL - * - device_key TEXT NULL + * - client_key TEXT NULL * - signature TEXT NULL */ public class UserParamEntry { @@ -18,17 +18,17 @@ public class UserParamEntry { private long timeMs; private String value; - private String deviceKey; + private String clientKey; private String signature; public UserParamEntry() {} - public UserParamEntry(String login, String param, long timeMs, String value, String deviceKey, String signature) { + public UserParamEntry(String login, String param, long timeMs, String value, String clientKey, String signature) { this.login = login; this.param = param; this.timeMs = timeMs; this.value = value; - this.deviceKey = deviceKey; + this.clientKey = clientKey; this.signature = signature; } @@ -44,8 +44,8 @@ public class UserParamEntry { public String getValue() { return value; } public void setValue(String value) { this.value = value; } - public String getDeviceKey() { return deviceKey; } - public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } + public String getClientKey() { return clientKey; } + public void setClientKey(String clientKey) { this.clientKey = clientKey; } public String getSignature() { return signature; } public void setSignature(String signature) { this.signature = signature; } diff --git a/SHiNE-server/shine-server-log/concat_to_file.sh b/SHiNE-server/shine-server-log/concat_to_file.sh deleted file mode 100755 index f6db1f1..0000000 --- a/SHiNE-server/shine-server-log/concat_to_file.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# собрать только *.java файлы и вывести их содержимое в файл -find . -type f -name "*.java" | sort | while read -r f; do - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Все .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/SHiNE-server/shine-server-net-protocol/all_files.txt b/SHiNE-server/shine-server-net-protocol/all_files.txt deleted file mode 100644 index 8d18326..0000000 --- a/SHiNE-server/shine-server-net-protocol/all_files.txt +++ /dev/null @@ -1,4739 +0,0 @@ -package server.logic.ws_protocol.JSON; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArraySet; - -/** - * Реестр активных подключений (только авторизованные). - */ -public final class ActiveConnectionsRegistry { - - private static final Logger log = LoggerFactory.getLogger(ActiveConnectionsRegistry.class); - - private static final ActiveConnectionsRegistry INSTANCE = new ActiveConnectionsRegistry(); - - public static ActiveConnectionsRegistry getInstance() { - return INSTANCE; - } - - private ActiveConnectionsRegistry() { - // singleton - } - - // sessionId (String) -> ConnectionContext - private final ConcurrentHashMap bySessionId = new ConcurrentHashMap<>(); - - // login (String) -> множество ConnectionContext для этого пользователя - private final ConcurrentHashMap> byLogin = new ConcurrentHashMap<>(); - - /** - * Зарегистрировать авторизованное подключение. - * Ожидается, что в ctx уже выставлены login и sessionId. - */ - public void register(ConnectionContext ctx) { - if (ctx == null) return; - - String sessionId = ctx.getSessionId(); - String login = ctx.getLogin(); - - if (sessionId == null || sessionId.isBlank() || login == null || login.isBlank()) { - log.debug("register skipped: bad ctx fields (login='{}', sessionId='{}')", login, sessionId); - return; - } - - // ✅ Если кто-то перерегистрировал тот же sessionId — вычищаем старый ctx из byLogin - ConnectionContext prev = bySessionId.put(sessionId, ctx); - if (prev != null && prev != ctx) { - String prevLogin = prev.getLogin(); - if (prevLogin != null && !prevLogin.isBlank()) { - Set prevSet = byLogin.get(prevLogin); - if (prevSet != null) { - prevSet.remove(prev); - if (prevSet.isEmpty()) { - byLogin.remove(prevLogin); - } - } - } - log.warn("sessionId reused: replaced previous ctx (sessionId={}, prevLogin={}, newLogin={})", - sessionId, prevLogin, login); - } - - byLogin - .computeIfAbsent(login, id -> new CopyOnWriteArraySet<>()) - .add(ctx); - - log.debug("registered ctx (login={}, sessionId={})", login, sessionId); - } - - /** - * Удалить подключение по контексту (например, при onClose). - */ - public void remove(ConnectionContext ctx) { - if (ctx == null) return; - - String sessionId = ctx.getSessionId(); - String login = ctx.getLogin(); - - if (sessionId != null && !sessionId.isBlank()) { - ConnectionContext removed = bySessionId.remove(sessionId); - - // Если в мапе лежал другой ctx под тем же sessionId — не трогаем его byLogin - if (removed != null && removed != ctx) { - log.debug("remove(ctx): sessionId mapped to another ctx, skip byLogin cleanup (sessionId={})", sessionId); - return; - } - } - - if (login != null && !login.isBlank()) { - Set set = byLogin.get(login); - if (set != null) { - set.remove(ctx); - if (set.isEmpty()) { - byLogin.remove(login); - } - } - } - - log.debug("removed ctx (login={}, sessionId={})", login, sessionId); - } - - /** - * Удалить подключение по sessionId. - */ - public void removeBySessionId(String sessionId) { - if (sessionId == null || sessionId.isBlank()) return; - - ConnectionContext ctx = bySessionId.remove(sessionId); - if (ctx == null) return; - - String login = ctx.getLogin(); - if (login != null && !login.isBlank()) { - Set set = byLogin.get(login); - if (set != null) { - set.remove(ctx); - if (set.isEmpty()) { - byLogin.remove(login); - } - } - } - - log.debug("removed by sessionId (login={}, sessionId={})", login, sessionId); - } - - /** - * Получить контекст по sessionId. - */ - public ConnectionContext getBySessionId(String sessionId) { - if (sessionId == null || sessionId.isBlank()) return null; - return bySessionId.get(sessionId); - } - - /** - * Получить все активные подключения пользователя по login. - */ - public Set getByLogin(String login) { - if (login == null || login.isBlank()) return Set.of(); - Set set = byLogin.get(login); - return (set == null) ? Set.of() : set; // CopyOnWriteArraySet можно отдавать как есть - } -} -package server.logic.ws_protocol.JSON; - -import org.eclipse.jetty.websocket.api.Session; -import shine.db.entities.SolanaUserEntry; -import shine.db.entities.ActiveSessionEntry; - -/** - * ConnectionContext — контекст состояния одного WebSocket-соединения. - * Живёт ровно столько же, сколько живёт подключение. - * - * Важно (v2): - * - Авторизация всегда 2 шага: - * A) Создание новой сессии через deviceKey: - * AuthChallenge(login) -> ctx.authNonce - * CreateAuthSession(...) -> ctx.AUTH_STATUS_USER + ctx.activeSession - * - * B) Вход в существующую сессию через sessionKey: - * SessionChallenge(sessionId) -> ctx.sessionLoginNonce + ctx.sessionLoginSessionId + expiresAt - * SessionLogin(...) -> проверка подписи sessionKey по pubkey из БД -> ctx.AUTH_STATUS_USER - */ -public class ConnectionContext { - - // Статусы аутентификации - public static final int AUTH_STATUS_NONE = 0; // анонимный / не авторизован - public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // выполнен challenge (AuthChallenge или SessionChallenge) - public static final int AUTH_STATUS_USER = 2; // авторизованный пользователь - - // Полный пользователь из БД (solana_users) - private SolanaUserEntry solanaUserEntry; - - // Активная сессия из БД (active_sessions) - private ActiveSessionEntry activeSessionEntry; - - /** - * Идентификатор сессии — base64-строка от 32 байт. - * Заполняется после успешного входа (AUTH_STATUS_USER). - */ - private String sessionId; - - /** - * Одноразовый nonce, выданный на шаге 1 (AuthChallenge), - * используется на шаге CreateAuthSession для проверки подписи deviceKey. - */ - private String authNonce; - - /* ===================== SessionLogin challenge (v2) ===================== */ - - /** - * Одноразовый nonce, выданный на шаге SessionChallenge(sessionId), - * используется на шаге SessionLogin для проверки подписи sessionKey. - */ - private String sessionLoginNonce; - - /** - * sessionId, для которого был выдан sessionLoginNonce. - * Нужен, чтобы SessionLogin не мог "подставить" другой sessionId. - */ - private String sessionLoginSessionId; - - /** - * Время истечения sessionLoginNonce (мс с 1970-01-01). - * Если текущее время > expiresAt, то nonce считается недействительным. - */ - private long sessionLoginNonceExpiresAtMs; - - /* ====================================================================== */ - - /** - * Текущий статус аутентификации. - * См. константы AUTH_STATUS_* - */ - private int authenticationStatus = AUTH_STATUS_NONE; - - /** - * WebSocket-сессия Jetty для данного подключения. - * Нужна, чтобы через ConnectionContext можно было отправлять сообщения клиенту. - */ - private Session wsSession; - - // --- WebSocket Session --- - - public Session getWsSession() { - return wsSession; - } - - public void setWsSession(Session wsSession) { - this.wsSession = wsSession; - } - - // --- SolanaUser / ActiveSession --- - - public SolanaUserEntry getSolanaUser() { - return solanaUserEntry; - } - - public void setSolanaUser(SolanaUserEntry solanaUserEntry) { - this.solanaUserEntry = solanaUserEntry; - } - - public ActiveSessionEntry getActiveSession() { - return activeSessionEntry; - } - - public void setActiveSession(ActiveSessionEntry activeSessionEntry) { - this.activeSessionEntry = activeSessionEntry; - } - - // --- Удобный геттер для логина --- - - public String getLogin() { - return solanaUserEntry != null ? solanaUserEntry.getLogin() : null; - } - - // --- sessionId --- - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } - - // --- authNonce --- - - public String getAuthNonce() { - return authNonce; - } - - public void setAuthNonce(String authNonce) { - this.authNonce = authNonce; - } - - // --- sessionLoginNonce (v2) --- - - public String getSessionLoginNonce() { - return sessionLoginNonce; - } - - public void setSessionLoginNonce(String sessionLoginNonce) { - this.sessionLoginNonce = sessionLoginNonce; - } - - public String getSessionLoginSessionId() { - return sessionLoginSessionId; - } - - public void setSessionLoginSessionId(String sessionLoginSessionId) { - this.sessionLoginSessionId = sessionLoginSessionId; - } - - public long getSessionLoginNonceExpiresAtMs() { - return sessionLoginNonceExpiresAtMs; - } - - public void setSessionLoginNonceExpiresAtMs(long sessionLoginNonceExpiresAtMs) { - this.sessionLoginNonceExpiresAtMs = sessionLoginNonceExpiresAtMs; - } - - // --- auth status --- - - public int getAuthenticationStatus() { - return authenticationStatus; - } - - public void setAuthenticationStatus(int authenticationStatus) { - this.authenticationStatus = authenticationStatus; - } - - public boolean isAuthenticatedUser() { - return authenticationStatus == AUTH_STATUS_USER; - } - - public boolean isAnonymous() { - return authenticationStatus == AUTH_STATUS_NONE; - } - - public void reset() { - solanaUserEntry = null; - activeSessionEntry = null; - - sessionId = null; - authNonce = null; - - sessionLoginNonce = null; - sessionLoginSessionId = null; - sessionLoginNonceExpiresAtMs = 0; - - authenticationStatus = AUTH_STATUS_NONE; - wsSession = null; - } - - @Override - public String toString() { - return "ConnectionContext{" + - "login='" + getLogin() + '\'' + - ", sessionId=" + sessionId + - ", authenticationStatus=" + authenticationStatus + - '}'; - } -} -package server.logic.ws_protocol.JSON.entyties; - -/** - * Базовый класс для всех событий (event). - * Общие поля: op и payload. - *. - * Формат JSON (event): - * { - * "op": "...", - * "payload": { ... } - * } - */ -public abstract class Net_Event { - - /** Имя операции / события (op). */ - private String op; - - /** - * Произвольные данные. - * В JSON это поле "payload". - */ - private Object payload; - - // --- getters / setters --- - - public String getOp() { - return op; - } - - public void setOp(String op) { - this.op = op; - } - - public Object getPayload() { - return payload; - } - - public void setPayload(Object payload) { - this.payload = payload; - } -} - -package server.logic.ws_protocol.JSON.entyties; - -/** - * Ответ с ошибкой (любой отказ). - *. - * В payload будет: - * { - * "code": "...", - * "message": "..." - * } - */ -public class Net_Exception_Response extends Net_Response { - - private String code; - private String message; - - public String getCode() { - return code; - } - - public void setCode(String code) { - this.code = code; - } - - public String getMessage() { - return message; - } - - public void setMessage(String message) { - this.message = message; - } -} - -package server.logic.ws_protocol.JSON.entyties; - -/** - * Базовый класс для всех запросов (client → server). - *. - * Наследуется от NetEvent и добавляет requestId. - *. - * Формат JSON (request): - * { - * "op": "...", - * "requestId": "...", - * "payload": { ... } - * } - */ -public abstract class Net_Request extends Net_Event { - - /** Идентификатор запроса, чтобы связать запрос и ответ. */ - private String requestId; - - // --- getters / setters --- - - public String getRequestId() { - return requestId; - } - - public void setRequestId(String requestId) { - this.requestId = requestId; - } -} - -package server.logic.ws_protocol.JSON.entyties; - -/** - * Базовый класс для всех ответов (server → client). - *. - * Наследуется от NetRequest и добавляет status. - *. - * Формат JSON (response): - * { - * "op": "...", - * "requestId": "...", - * "status": 200, - * "payload": { ... } // и для успеха, и для ошибки - * } - */ -public abstract class Net_Response extends Net_Request { - - /** Статус результата (200 — успех, любое другое значение — ошибка). */ - private int status; - - // --- getters / setters --- - - public int getStatus() { - return status; - } - - public void setStatus(int status) { - this.status = status; - } - - public boolean isOk() { - return status == 200; - } -} - -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 1 авторизации: запрос выдачи одноразового nonce (authNonce). - * - * Клиент по логину просит сервер сгенерировать случайный authNonce, - * который будет использован на втором шаге при подписи. - * - * Формат входящего JSON: - * { - * "op": "AuthChallenge", - * "requestId": "...", - * "payload": { - * "login": "someLogin" - * } - * } - * - * Формат успешного ответа: - * { - * "op": "AuthChallenge", - * "requestId": "...", - * "status": 200, - * "payload": { - * "authNonce": "base64-строка-от-32-байт" - * } - * } - */ -public class Net_AuthChallenge_Request extends Net_Request { - - /** - * Логин пользователя, для которого запускается авторизация. - */ - private String login; - - public String getLogin() { - return login; - } - public void setLogin(String login) { - this.login = login; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на AuthChallenge. - * - * При успехе сервер возвращает одноразовый nonce для подписи (authNonce), - * который клиент обязан использовать на втором шаге при формировании строки - * для цифровой подписи. - * - * JSON: - * { - * "op": "AuthChallenge", - * "requestId": "...", - * "status": 200, - * "payload": { - * "authNonce": "base64-строка-от-32-байт" - * } - * } - */ -public class Net_AuthChallenge_Response extends Net_Response { - - /** - * Одноразовый nonce для авторификации. - * Строка — это base64-представление 32 случайных байт. - */ - private String authNonce; - - public String getAuthNonce() { - return authNonce; - } - - public void setAuthNonce(String authNonce) { - this.authNonce = authNonce; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос CloseActiveSession — закрытие активной сессии пользователя. - * - * Новая логика (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Никаких подписей и "AUTH_IN_PROGRESS" здесь больше нет. - * - * payload: - * { - * "sessionId": "..." // опционально; если пусто — закрываем текущую - * } - */ -public class Net_CloseActiveSession_Request extends Net_Request { - - /** Идентификатор сессии, которую нужно закрыть. Может быть пустым. */ - private String sessionId; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на CloseActiveSession. - * - * При успехе: - * - status = 200; - * - payload = {}. - * - * Закрытие WebSocket-соединения может быть выполнено сразу (для другой сессии) - * или чуть позже (для текущей сессии) после отправки ответа. - */ -public class Net_CloseActiveSession_Response extends Net_Response { - // Дополнительных полей пока не требуется. -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 2 (v2): создание новой сессии ТОЛЬКО через deviceKey. - * - * Шаги: - * 1) AuthChallenge(login) -> authNonce - * 2) CreateAuthSession(storagePwd, sessionPubKeyB64, timeMs, signatureB64, clientInfo) - * - * Подпись deviceKey делается над строкой (UTF-8): - * AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce}:{sessionPubKeyB64}:{storagePwd} - * - * Важно: - * - sessionKey генерируется на клиенте, на сервер отправляется ТОЛЬКО sessionPubKeyB64 (32 bytes base64). - * - В БД active_sessions.session_key хранится sessionPubKeyB64. - */ -public class Net_CreateAuthSession_Request extends Net_Request { - - /** Клиентский пароль для хранения данных (base64url от 32 байт). */ - private String storagePwd; - - /** Публичный ключ сессии (sessionPubKey), base64 от 32 байт. */ - private String sessionPubKeyB64; - - /** Время на стороне клиента (мс с 1970-01-01). */ - private long timeMs; - - /** Подпись Ed25519(deviceKey) над строкой AUTH_CREATE_SESSION:... (base64). */ - private String signatureB64; - - /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ - private String clientInfo; - - public String getStoragePwd() { - return storagePwd; - } - - public void setStoragePwd(String storagePwd) { - this.storagePwd = storagePwd; - } - - public String getSessionPubKeyB64() { - return sessionPubKeyB64; - } - - public void setSessionPubKeyB64(String sessionPubKeyB64) { - this.sessionPubKeyB64 = sessionPubKeyB64; - } - - public long getTimeMs() { - return timeMs; - } - - public void setTimeMs(long timeMs) { - this.timeMs = timeMs; - } - - public String getSignatureB64() { - return signatureB64; - } - - public void setSignatureB64(String signatureB64) { - this.signatureB64 = signatureB64; - } - - public String getClientInfo() { - return clientInfo; - } - - public void setClientInfo(String clientInfo) { - this.clientInfo = clientInfo; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на CreateAuthSession (v2). - * - * При успехе сервер создаёт запись в active_sessions - * и возвращает идентификатор сессии sessionId. - * - * JSON: - * { - * "op": "CreateAuthSession", - * "requestId": "...", - * "status": 200, - * "payload": { - * "sessionId": "base64url(32)" - * } - * } - */ -public class Net_CreateAuthSession_Response extends Net_Response { - - /** Идентификатор сессии, base64url от 32 байт. */ - private String sessionId; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос ListSessions — список активных сессий пользователя. - * - * Новая логика (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Пустой payload. - */ -public class Net_ListSessions_Request extends Net_Request { - // пусто -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.List; - -/** - * Ответ на ListSessions. - * - * При успехе: - * - status = 200; - * - payload: - * { - * "sessions": [ - * { - * "sessionId": "...", - * "clientInfoFromClient": "...", - * "clientInfoFromRequest": "...", - * "geo": "Country, City" | "unknown", - * "lastAuthirificatedAtMs": 1733310000000 - * }, - * ... - * ] - * } - */ -public class Net_ListSessions_Response extends Net_Response { - - /** - * Список активных сессий для текущего пользователя. - */ - private List sessions; - - public List getSessions() { - return sessions; - } - - public void setSessions(List sessions) { - this.sessions = sessions; - } - - /** - * Описание одной активной сессии. - */ - public static class SessionInfo { - - /** Идентификатор сессии, base64 от 32 байт. */ - private String sessionId; - - /** Что прислал клиент в CreateAuthSession/RefreshSession (clientInfo). */ - private String clientInfoFromClient; - - /** Краткая строка, собранная сервером из HTTP-запроса (UA, платформа и т.п.). */ - private String clientInfoFromRequest; - - /** Строка геолокации вида "Country, City" или "unknown". */ - private String geo; - - /** Время последней успешной авторизации/refresh (мс с 1970-01-01). */ - private long lastAuthirificatedAtMs; - - // --- getters / setters --- - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } - - public String getClientInfoFromClient() { - return clientInfoFromClient; - } - - public void setClientInfoFromClient(String clientInfoFromClient) { - this.clientInfoFromClient = clientInfoFromClient; - } - - public String getClientInfoFromRequest() { - return clientInfoFromRequest; - } - - public void setClientInfoFromRequest(String clientInfoFromRequest) { - this.clientInfoFromRequest = clientInfoFromRequest; - } - - public String getGeo() { - return geo; - } - - public void setGeo(String geo) { - this.geo = geo; - } - - public long getLastAuthirificatedAtMs() { - return lastAuthirificatedAtMs; - } - - public void setLastAuthirificatedAtMs(long lastAuthirificatedAtMs) { - this.lastAuthirificatedAtMs = lastAuthirificatedAtMs; - } - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 1 входа в существующую сессию (v2): - * SessionChallenge(sessionId) -> nonce - */ -public class Net_SessionChallenge_Request extends Net_Request { - - private String sessionId; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на SessionChallenge (v2). - * payload: { "nonce": "base64url(32)" } - */ -public class Net_SessionChallenge_Response extends Net_Response { - - private String nonce; - - public String getNonce() { - return nonce; - } - - public void setNonce(String nonce) { - this.nonce = nonce; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 2 входа в существующую сессию (v2): - * SessionLogin(sessionId, timeMs, signatureB64) -> storagePwd, AUTH_STATUS_USER - * - * Подпись делается sessionKey (приватный ключ на устройстве) над строкой (UTF-8): - * SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} - * - * nonce берётся из SessionChallenge и хранится в ctx (одноразовый, TTL). - */ -public class Net_SessionLogin_Request extends Net_Request { - - private String sessionId; - private long timeMs; - private String signatureB64; - - /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ - private String clientInfo; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } - - public long getTimeMs() { - return timeMs; - } - - public void setTimeMs(long timeMs) { - this.timeMs = timeMs; - } - - public String getSignatureB64() { - return signatureB64; - } - - public void setSignatureB64(String signatureB64) { - this.signatureB64 = signatureB64; - } - - public String getClientInfo() { - return clientInfo; - } - - public void setClientInfo(String clientInfo) { - this.clientInfo = clientInfo; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на SessionLogin (v2). - * payload: { "storagePwd": "base64url(32)" } - */ -public class Net_SessionLogin_Response extends Net_Response { - - private String storagePwd; - - public String getStoragePwd() { - return storagePwd; - } - - public void setStoragePwd(String storagePwd) { - this.storagePwd = storagePwd; - } -} -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_AuthChallenge_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; - -import java.security.SecureRandom; -import java.util.Base64; - -/** - * AuthChallenge (v2) — шаг 1 создания новой сессии. - * - * Логика авторизации (v2): - * - Создание новой сессии возможно ТОЛЬКО через deviceKey пользователя. - * - Этот handler выдаёт одноразовый authNonce, который клиент использует во втором шаге: - * CreateAuthSession(..., signature(deviceKey, AUTH_CREATE_SESSION:...)) - * - * Что делает: - * 1) Проверяет login. - * 2) Находит пользователя (solana_users). - * 3) Пишет solanaUser в ctx, ставит AUTH_STATUS_AUTH_IN_PROGRESS. - * 4) Генерирует authNonce (base64url(32)) и сохраняет в ctx.authNonce. - */ -public class Net_AuthChallenge_Handler implements JsonMessageHandler { - - private static final SecureRandom RANDOM = new SecureRandom(); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - - Net_AuthChallenge_Request req = (Net_AuthChallenge_Request) baseReq; - - String login = req.getLogin(); - if (login == null || login.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_LOGIN", - "Пустой логин" - ); - } - - // Если по этому соединению уже есть залогиненный пользователь — не даём повторную авторификацию - if (ctx.getLogin() != null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "ALREADY_AUTHED", - "Попытка повторной авторификации для уже заданного login=" + ctx.getLogin() - ); - } - - SolanaUserEntry solanaUserEntry = SolanaUsersDAO.getInstance().getByLogin(login); - if (solanaUserEntry == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "UNKNOWN_USER", - "Пользователь с таким логином не найден" - ); - } - - ctx.setSolanaUser(solanaUserEntry); - ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS); - - byte[] buf = new byte[32]; - RANDOM.nextBytes(buf); - String authNonce = Base64.getUrlEncoder().withoutPadding().encodeToString(buf); - - ctx.setAuthNonce(authNonce); - - Net_AuthChallenge_Response resp = new Net_AuthChallenge_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setAuthNonce(authNonce); - - return resp; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -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_CloseActiveSession_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import server.ws.WsConnectionUtils; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; - -import java.sql.SQLException; - -/** - * CloseActiveSession (v2) — закрытие текущей или другой сессии. - * - * Логика авторизации (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Никаких подписей и AUTH_IN_PROGRESS здесь больше нет. - * - * Закрытие: - * - удаляем запись из БД - * - если по sessionId есть активный WS — закрываем его - */ -public class Net_CloseActiveSession_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_CloseActiveSession_Handler.class); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_CloseActiveSession_Request req = (Net_CloseActiveSession_Request) baseReq; - - if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "NOT_AUTHENTICATED", - "Операция доступна только для авторизованных пользователей" - ); - } - - SolanaUserEntry user = ctx.getSolanaUser(); - String currentLogin = user.getLogin(); - - String targetSessionId = req.getSessionId(); - if (targetSessionId == null || targetSessionId.isBlank()) { - if (ctx.getSessionId() != null && !ctx.getSessionId().isBlank()) { - targetSessionId = ctx.getSessionId(); - } else if (ctx.getActiveSession() != null && ctx.getActiveSession().getSessionId() != null) { - targetSessionId = ctx.getActiveSession().getSessionId(); - } else { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_SESSION_TO_CLOSE", - "Не удалось определить, какую сессию нужно закрыть" - ); - } - } - - ActiveSessionEntry targetSession; - try { - targetSession = ActiveSessionsDAO.getInstance().getBySessionId(targetSessionId); - } catch (SQLException e) { - log.error("Ошибка БД при поиске сессии для CloseActiveSession sessionId={}", targetSessionId, e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка доступа к базе данных при поиске сессии" - ); - } - - if (targetSession == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_NOT_FOUND", - "Сессия для закрытия не найдена" - ); - } - - if (currentLogin == null || !currentLogin.equals(targetSession.getLogin())) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_OF_ANOTHER_USER", - "Нельзя закрывать сессию другого пользователя" - ); - } - - boolean isCurrentSession = targetSessionId.equals(ctx.getSessionId()); - - closeActiveSession(targetSessionId, ctx, isCurrentSession); - - Net_CloseActiveSession_Response resp = new Net_CloseActiveSession_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - return resp; - } - - private void closeActiveSession(String targetSessionId, - ConnectionContext currentCtx, - boolean isCurrentSession) { - - try { - ActiveSessionsDAO.getInstance().deleteBySessionId(targetSessionId); - } catch (SQLException e) { - log.error("Ошибка БД при удалении сессии sessionId={}", targetSessionId, e); - } - - ConnectionContext ctxToClose = - ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId); - - if (ctxToClose == null) return; - - if (isCurrentSession && ctxToClose == currentCtx) { - new Thread(() -> { - try { Thread.sleep(50); } catch (InterruptedException ignored) {} - WsConnectionUtils.closeConnection( - ctxToClose, - 4000, - "Session closed by client via CloseActiveSession" - ); - }, "CloseSession-" + targetSessionId).start(); - } else { - WsConnectionUtils.closeConnection( - ctxToClose, - 4000, - "Session closed by client via CloseActiveSession" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.eclipse.jetty.websocket.api.Session; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -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_CreateAuthSession_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import server.ws.WsConnectionUtils; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; -import shine.geo.ClientInfoService; -import shine.geo.GeoLookupService; -import utils.crypto.Ed25519Util; - -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.sql.SQLException; -import java.util.Base64; - -/** - * CreateAuthSession (v2) — шаг 2 создания новой сессии (ТОЛЬКО deviceKey). - * - * Логика авторизации (v2): - * - Создание сессии: AuthChallenge(login) -> authNonce -> CreateAuthSession(...) - * - Клиент генерирует sessionKey (Ed25519), хранит приватный ключ у себя, - * отправляет на сервер ТОЛЬКО sessionPubKeyB64. - * - Сервер сохраняет sessionPubKeyB64 в active_sessions.session_key. - * - * Подпись deviceKey (Ed25519) проверяется над строкой (UTF-8): - * AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce} - * - * На выходе: - * - создаётся запись active_sessions - * - ctx становится AUTH_STATUS_USER (вход выполнен как "текущая сессия") - * - ответ: sessionId - */ -public class Net_CreateAuthSession__Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class); - private static final SecureRandom RANDOM = new SecureRandom(); - - public static final long ALLOWED_SKEW_MS = 30_000L; - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - - Net_CreateAuthSession_Request req = (Net_CreateAuthSession_Request) baseReq; - - if (ctx == null - || ctx.getSolanaUser() == null - || ctx.getAuthNonce() == null - || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS) { - - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_STEP1_CONTEXT", - "Шаг 1 авторизации не был корректно выполнен для данного соединения" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no step1 context or bad auth state"); - return err; - } - - SolanaUserEntry user = ctx.getSolanaUser(); - String login = user.getLogin(); - if (login == null || login.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "NO_LOGIN", - "Для пользователя не задан login в БД" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no login"); - return err; - } - - String storagePwd = req.getStoragePwd(); - if (storagePwd == null || storagePwd.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_STORAGE_PWD", - "Пустой storagePwd" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty storagePwd"); - return err; - } - - String sessionPubKeyB64 = req.getSessionPubKeyB64(); - if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SESSION_PUBKEY", - "Пустой sessionPubKeyB64" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty session pubkey"); - return err; - } - - // Проверим, что sessionPubKeyB64 декодируется в 32 байта - byte[] sessionPubKey32; - try { - sessionPubKey32 = decodeBase64Any(sessionPubKeyB64); - } catch (IllegalArgumentException e) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "Некорректный base64 в sessionPubKeyB64" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session pubkey base64"); - return err; - } - if (sessionPubKey32.length != 32) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_SESSION_PUBKEY_LEN", - "sessionPubKey должен быть 32 байта" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session pubkey length"); - return err; - } - - String signatureB64 = req.getSignatureB64(); - if (signatureB64 == null || signatureB64.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SIGNATURE", - "Пустая цифровая подпись" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty signature"); - return err; - } - - long timeMs = req.getTimeMs(); - long nowMs = System.currentTimeMillis(); - long diff = Math.abs(nowMs - timeMs); - if (diff > ALLOWED_SKEW_MS) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "TIME_SKEW", - "Время клиента отличается от сервера более чем на 30 секунд" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: time skew"); - return err; - } - - String clientInfoFromClient = req.getClientInfo(); - if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) { - clientInfoFromClient = clientInfoFromClient.substring(0, 50); - } - - String devicePubKeyB64 = user.getDeviceKey(); - if (devicePubKeyB64 == null || devicePubKeyB64.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_DEVICE_KEY", - "Отсутствует deviceKey у пользователя" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no deviceKey"); - return err; - } - - String authNonce = ctx.getAuthNonce(); - - boolean sigOk; - try { - sigOk = verifyCreateSessionSignature( - user, - login, - authNonce, - timeMs, - signatureB64 - ); - } catch (IllegalArgumentException ex) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "Некорректный формат Base64 для ключа или подписи" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad base64"); - return err; - } - - if (!sigOk) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "BAD_SIGNATURE", - "Подпись не прошла проверку" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad signature"); - return err; - } - - // --- генерируем sessionId --- - String sessionId = generateRandom32B64Url(); - long now = System.currentTimeMillis(); - - // --- Сбор данных о клиенте (IP, UA, язык) --- - Session wsSession = ctx.getWsSession(); - String clientInfoFromRequest = ClientInfoService.buildClientInfoString(wsSession); - String userLanguage = ClientInfoService.extractPreferredLanguageTag(wsSession); - - String clientIp = ""; - if (wsSession != null) { - String ip = ClientInfoService.extractClientIp(wsSession); - if (ip != null) clientIp = ip; - - if (!clientIp.isBlank()) { - try { - GeoLookupService.resolveCountryCityOrIpWithCache(clientIp); - } catch (Exception e) { - log.debug("Geo lookup failed for ip={}", clientIp, e); - } - } - } - - // --- создаём запись ActiveSession и сохраняем в БД --- - ActiveSessionsDAO dao = ActiveSessionsDAO.getInstance(); - ActiveSessionEntry activeSessionEntry; - - try { - activeSessionEntry = new ActiveSessionEntry( - sessionId, - login, - sessionPubKeyB64, // session_key (pubkey) - storagePwd, - now, - now, - null, // pushEndpoint - null, // pushP256dhKey - null, // pushAuthKey - clientIp, - clientInfoFromClient, - clientInfoFromRequest, - userLanguage - ); - - dao.insert(activeSessionEntry); - } catch (SQLException e) { - log.error("Ошибка БД при создании новой сессии для login={}", login, e); - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR_SESSION_CREATE", - "Ошибка БД при создании сессии" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: db error"); - return err; - } - - // --- обновляем контекст --- - ctx.setActiveSession(activeSessionEntry); - ctx.setSessionId(sessionId); - ctx.setAuthNonce(null); - ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); - - ActiveConnectionsRegistry.getInstance().register(ctx); - - // --- формируем ответ --- - Net_CreateAuthSession_Response resp = new Net_CreateAuthSession_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setSessionId(sessionId); - return resp; - } - - private static boolean verifyCreateSessionSignature( - SolanaUserEntry user, - String login, - String authNonce, - long timeMs, - String signatureB64 - ) throws IllegalArgumentException { - - // deviceKey (pub, 32) - byte[] publicKey32 = Ed25519Util.keyFromBase64(user.getDeviceKey()); - byte[] signature64 = decodeBase64Any(signatureB64); - - String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce; - byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); - - return Ed25519Util.verify(preimage, signature64, publicKey32); - } - - private static String generateRandom32B64Url() { - byte[] buf = new byte[32]; - RANDOM.nextBytes(buf); - return Base64.getUrlEncoder().withoutPadding().encodeToString(buf); - } - - private static byte[] decodeBase64Any(String s) throws IllegalArgumentException { - if (s == null) throw new IllegalArgumentException("base64 is null"); - String x = s.trim(); - if (x.isEmpty()) throw new IllegalArgumentException("base64 is empty"); - - // сначала url-safe, потом обычный - try { - return Base64.getUrlDecoder().decode(x); - } catch (IllegalArgumentException ignore) { - return Base64.getDecoder().decode(x); - } - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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_ListSessions_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Response; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Response.SessionInfo; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; -import shine.geo.GeoLookupService; - -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.List; - -/** - * ListSessions (v2) — список активных сессий. - * - * Логика авторизации (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Никаких подписей здесь больше нет. - */ -public class Net_ListSessions_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_ListSessions_Handler.class); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_ListSessions_Request req = (Net_ListSessions_Request) baseReq; - - if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "NOT_AUTHENTICATED", - "Операция доступна только для авторизованных пользователей" - ); - } - - SolanaUserEntry user = ctx.getSolanaUser(); - String currentLogin = user.getLogin(); - - List sessions; - try { - sessions = ActiveSessionsDAO.getInstance().getByLogin(currentLogin); - } catch (SQLException e) { - log.error("Ошибка БД при получении списка сессий для login={}", currentLogin, e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR_LIST_SESSIONS", - "Ошибка доступа к базе данных при получении списка сессий" - ); - } - - List resultList = new ArrayList<>(); - for (ActiveSessionEntry s : sessions) { - SessionInfo info = new SessionInfo(); - info.setSessionId(s.getSessionId()); - info.setClientInfoFromClient(s.getClientInfoFromClient()); - info.setClientInfoFromRequest(s.getClientInfoFromRequest()); - info.setLastAuthirificatedAtMs(s.getLastAuthirificatedAtMs()); - - String ip = s.getClientIp(); - String geo = GeoLookupService.resolveCountryCityOrIpWithCache(ip); - info.setGeo(geo); - - resultList.add(info); - } - - Net_ListSessions_Response resp = new Net_ListSessions_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setSessions(resultList); - - return resp; - } -} -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_SessionChallenge_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; - -import java.security.SecureRandom; -import java.sql.SQLException; -import java.util.Base64; - -/** - * SessionChallenge (v2) — шаг 1 входа в существующую сессию. - * - * Логика авторизации (v2): - * - Вход в существующую сессию ВСЕГДА в 2 шага: - * 1) SessionChallenge(sessionId) -> nonce - * 2) SessionLogin(sessionId, timeMs, signature(sessionKey, SESSION_LOGIN:...)) - * - * Что делает: - * - Проверяет, что sessionId существует в БД. - * - Генерирует одноразовый nonce (base64url(32)), сохраняет его в ctx: - * ctx.sessionLoginNonce, ctx.sessionLoginSessionId, ctx.sessionLoginNonceExpiresAtMs. - */ -public class Net_SessionChallenge_Handler implements JsonMessageHandler { - - private static final SecureRandom RANDOM = new SecureRandom(); - private static final long NONCE_TTL_MS = 60_000L; - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_SessionChallenge_Request req = (Net_SessionChallenge_Request) baseReq; - - String sessionId = req.getSessionId(); - if (sessionId == null || sessionId.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SESSION_ID", - "Пустой sessionId" - ); - } - - ActiveSessionEntry session; - try { - session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId); - } catch (SQLException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка доступа к базе данных" - ); - } - - if (session == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_NOT_FOUND", - "Сессия не найдена" - ); - } - - byte[] buf = new byte[32]; - RANDOM.nextBytes(buf); - String nonce = Base64.getUrlEncoder().withoutPadding().encodeToString(buf); - - long now = System.currentTimeMillis(); - ctx.setSessionLoginNonce(nonce); - ctx.setSessionLoginSessionId(sessionId); - ctx.setSessionLoginNonceExpiresAtMs(now + NONCE_TTL_MS); - - Net_SessionChallenge_Response resp = new Net_SessionChallenge_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setNonce(nonce); - return resp; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -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_SessionLogin_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; -import shine.geo.ClientInfoService; -import shine.geo.GeoLookupService; -import utils.crypto.Ed25519Util; - -import java.nio.charset.StandardCharsets; -import java.sql.SQLException; -import java.util.Base64; - -/** - * SessionLogin (v2) — шаг 2 входа в существующую сессию (по sessionKey). - * - * Логика авторизации (v2): - * - SessionChallenge(sessionId) выдаёт nonce (одноразовый, TTL). - * - SessionLogin проверяет подпись sessionKey над строкой: - * SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} - * - sessionPubKey берём из БД: active_sessions.session_key (base64 32 bytes). - * - * При успехе: - * - ctx становится AUTH_STATUS_USER - * - обновляем метаданные сессии (lastAuth + clientIp + clientInfo + lang) - * - возвращаем storagePwd - */ -public class Net_SessionLogin_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_SessionLogin_Handler.class); - - private static final long ALLOWED_SKEW_MS = 30_000L; - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_SessionLogin_Request req = (Net_SessionLogin_Request) baseReq; - - String sessionId = req.getSessionId(); - if (sessionId == null || sessionId.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SESSION_ID", - "Пустой sessionId" - ); - } - - // проверка челленджа - if (ctx.getSessionLoginNonce() == null - || ctx.getSessionLoginSessionId() == null - || System.currentTimeMillis() > ctx.getSessionLoginNonceExpiresAtMs()) { - - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_CHALLENGE", - "Нет активного SessionChallenge или nonce истёк" - ); - } - - if (!sessionId.equals(ctx.getSessionLoginSessionId())) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "SESSION_ID_MISMATCH", - "nonce был выдан для другого sessionId" - ); - } - - long timeMs = req.getTimeMs(); - long nowMs = System.currentTimeMillis(); - if (Math.abs(nowMs - timeMs) > ALLOWED_SKEW_MS) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "TIME_SKEW", - "Время клиента отличается от сервера более чем на 30 секунд" - ); - } - - String signatureB64 = req.getSignatureB64(); - if (signatureB64 == null || signatureB64.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SIGNATURE", - "Пустая подпись" - ); - } - - ActiveSessionEntry session; - try { - session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId); - } catch (SQLException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка доступа к базе данных" - ); - } - - if (session == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_NOT_FOUND", - "Сессия не найдена" - ); - } - - String sessionPubKeyB64 = session.getSessionKey(); // это pubKey - if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "NO_SESSION_KEY", - "В сессии не задан session_key" - ); - } - - String nonce = ctx.getSessionLoginNonce(); - - boolean sigOk; - try { - sigOk = verifySessionLoginSignature(sessionPubKeyB64, sessionId, timeMs, nonce, signatureB64); - } catch (IllegalArgumentException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "Некорректный Base64 для ключа/подписи" - ); - } - - if (!sigOk) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "BAD_SIGNATURE", - "Подпись не прошла проверку" - ); - } - - // сжигаем nonce - ctx.setSessionLoginNonce(null); - ctx.setSessionLoginSessionId(null); - ctx.setSessionLoginNonceExpiresAtMs(0); - - // подтягиваем пользователя - SolanaUserEntry user; - try { - user = SolanaUsersDAO.getInstance().getByLogin(session.getLogin()); - } catch (SQLException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR_USER_LOOKUP", - "Ошибка доступа к базе данных при получении пользователя" - ); - } - - if (user == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "USER_NOT_FOUND_FOR_SESSION", - "Пользователь для данной сессии не найден" - ); - } - - // обновление метаданных - String clientInfoFromClient = req.getClientInfo(); - if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) { - clientInfoFromClient = clientInfoFromClient.substring(0, 50); - } - - String clientIp = null; - String clientInfoFromRequest = null; - String userLanguage = null; - - if (ctx.getWsSession() != null) { - clientIp = ClientInfoService.extractClientIp(ctx.getWsSession()); - clientInfoFromRequest = ClientInfoService.buildClientInfoString(ctx.getWsSession()); - userLanguage = ClientInfoService.extractPreferredLanguageTag(ctx.getWsSession()); - - if (clientIp != null && !clientIp.isBlank()) { - try { - GeoLookupService.resolveCountryCityOrIpWithCache(clientIp); - } catch (Exception e) { - log.debug("Geo lookup failed for ip={}", clientIp, e); - } - } - } - - long now = System.currentTimeMillis(); - try { - ActiveSessionsDAO.getInstance().updateOnRefresh( - sessionId, - now, - clientIp, - clientInfoFromClient, - clientInfoFromRequest, - userLanguage - ); - } catch (SQLException e) { - log.error("Ошибка БД при updateOnRefresh sessionId={}", sessionId, e); - } - - session.setLastAuthirificatedAtMs(now); - session.setClientIp(clientIp); - session.setClientInfoFromClient(clientInfoFromClient); - session.setClientInfoFromRequest(clientInfoFromRequest); - session.setUserLanguage(userLanguage); - - // ctx - ctx.setActiveSession(session); - ctx.setSolanaUser(user); - ctx.setSessionId(sessionId); - ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); - - ActiveConnectionsRegistry.getInstance().register(ctx); - - // ответ - Net_SessionLogin_Response resp = new Net_SessionLogin_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setStoragePwd(session.getStoragePwd()); - return resp; - } - - private static boolean verifySessionLoginSignature( - String sessionPubKeyB64, - String sessionId, - long timeMs, - String nonce, - String signatureB64 - ) throws IllegalArgumentException { - - byte[] publicKey32 = Ed25519Util.keyFromBase64(sessionPubKeyB64); - byte[] signature64 = decodeBase64Any(signatureB64); - - String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce; - byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); - - return Ed25519Util.verify(preimage, signature64, publicKey32); - } - - private static byte[] decodeBase64Any(String s) throws IllegalArgumentException { - try { - return Base64.getUrlDecoder().decode(s); - } catch (IllegalArgumentException ignore) { - return Base64.getDecoder().decode(s); - } - } -} -package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -public final class Net_AddBlock_Request extends Net_Request { - - private String blockchainName; // обязателен - private int blockNumber; // обязателен - private String prevBlockHash; // HEX(64) или "" для нулевого - private String blockBytesB64; // байты FULL-блока (raw+sig+hash) в Base64 - - public String getBlockchainName() { return blockchainName; } - public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } - - public int getBlockNumber() { return blockNumber; } - public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; } - - public String getPrevBlockHash() { return prevBlockHash; } - public void setPrevBlockHash(String prevBlockHash) { this.prevBlockHash = prevBlockHash; } - - public String getBlockBytesB64() { return blockBytesB64; } - public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; } -} -package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ: - * - reasonCode (null если ok) - * - serverLastGlobalNumber / serverLastGlobalHash - */ -public final class Net_AddBlock_Response extends Net_Response { - - /** null если ok, иначе строка причины (bad_block_base64, user_not_found, и т.п.) */ - private String reasonCode; - - /** что сервер считает последним по глобальной цепочке */ - private int serverLastGlobalNumber; - private String serverLastGlobalHash; - - public String getReasonCode() { return reasonCode; } - public void setReasonCode(String reasonCode) { this.reasonCode = reasonCode; } - - public int getServerLastGlobalNumber() { return serverLastGlobalNumber; } - public void setServerLastGlobalNumber(int v) { this.serverLastGlobalNumber = v; } - - public String getServerLastGlobalHash() { return serverLastGlobalHash; } - public void setServerLastGlobalHash(String v) { this.serverLastGlobalHash = v; } -} -package server.logic.ws_protocol.JSON.handlers.blockchain; - -import blockchain.BchBlockEntry; -import blockchain.BchCryptoVerifier; -import blockchain.MsgSubType; -import blockchain.body.BodyHasLine; -import blockchain.body.BodyHasTarget; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks; -import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter; -import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; -import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.BlocksDAO; -import shine.db.entities.BlockchainStateEntry; -import shine.db.entities.BlockEntry; -import utils.blockchain.BlockchainNameUtil; - -import java.util.Arrays; -import java.util.Base64; -import java.util.concurrent.locks.ReentrantLock; - -/** - * Net_AddBlock_Handler — единый хэндлер добавления блока (JSON). - * - * Новый порядок валидации (ТЗ): - * 1) Достаём из blockchain_state: last_block_number, last_block_hash - * 2) Проверяем: - * - incoming.blockNumber == last+1 - * - incoming.prevHash32 == last_hash (для genesis last_hash = 32 нулей) - * 3) Проверяем подпись Ed25519.verify(hash32(preimage), signature64, pubKey) - * 4) Если тип имеет линию: - * - если prevLineNumber != null: - * достаём hash блока prevLineNumber из blocks - * сравниваем с prevLineHash32 из body - * 5) Сохраняем блок в blocks + обновляем blockchain_state - * - * Важно: - * - Сетевой протокол AddBlock пока оставляем старые поля (globalNumber/prevGlobalHash), - * но внутренняя логика использует НОВЫЙ формат блока. - */ -public final class Net_AddBlock_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_AddBlock_Handler.class); - - private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); - private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); - - private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) { - - Net_AddBlock_Request req = (Net_AddBlock_Request) baseReq; - - String blockchainName = req.getBlockchainName(); - ReentrantLock lock = BlockchainLocks.lockFor(blockchainName); - lock.lock(); - try { - AddBlockResult r = addBlock( - blockchainName, - req.getBlockNumber(), // старое поле, пока оставляем - req.getPrevBlockHash(), // старое поле, пока оставляем - req.getBlockBytesB64() - ); - - Net_AddBlock_Response resp = new Net_AddBlock_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - - if (r.isOk()) { - resp.setStatus(WireCodes.Status.OK); - resp.setReasonCode(null); - } else { - resp.setStatus(r.httpStatus); - resp.setReasonCode(r.reasonCode); - } - - resp.setServerLastGlobalNumber(r.serverLastBlockNumber); - resp.setServerLastGlobalHash(r.serverLastBlockHashHex); - - return resp; - - } finally { - lock.unlock(); - } - } - - private AddBlockResult addBlock( - String blockchainName, - int globalNumberFromReq, - String prevGlobalHashHexFromReq, - String blockBytesB64 - ) { - if (blockchainName == null || blockchainName.isBlank()) { - log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "empty_blockchain_name", 0, ""); - } - - String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName); - if (login == null || login.isBlank()) { - log.warn("AddBlock: плохой blockchainName='{}' => login не получился (reqGlobalNumber={})", - blockchainName, globalNumberFromReq); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_name", 0, ""); - } - - // 1) state обязателен - final BlockchainStateEntry st; - try { - st = stateDAO.getByBlockchainName(blockchainName); - } catch (Exception e) { - log.error("AddBlock: ошибка БД при чтении blockchain_state (login={}, blockchainName={}, reqGlobalNumber={})", - login, blockchainName, globalNumberFromReq, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "db_error", 0, ""); - } - - if (st == null) { - log.warn("AddBlock: blockchain_state_not_found (login={}, blockchainName={}, reqGlobalNumber={})", - login, blockchainName, globalNumberFromReq); - return new AddBlockResult(WireCodes.Status.NOT_FOUND, "blockchain_state_not_found", -1, ""); - } - - final int serverLastNum = st.getLastBlockNumber(); - final byte[] serverLastHash32 = (serverLastNum < 0) - ? new byte[32] - : require32OrThrow(st.getLastBlockHash(), "state.last_block_hash is null/invalid"); - - final String serverLastHashHex = toHex(serverLastHash32); - - // 2) decode block - final byte[] blockBytes; - try { - blockBytes = decodeBase64(blockBytesB64); - } catch (Exception e) { - log.warn("AddBlock: некорректный base64 блока (login={}, blockchainName={}, reqGlobalNumber={})", - login, blockchainName, globalNumberFromReq, e); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_base64", serverLastNum, serverLastHashHex); - } - - // 3) лимит (оставляем как было) - try { - long oldSize = st.getFileSizeBytes(); - long limit = st.getSizeLimit(); - long newSize = safeAdd(oldSize, blockBytes.length); - - if (limit > 0 && newSize > limit) { - log.warn("AddBlock: limit_exceeded (login={}, blockchainName={}, oldSize={}, addLen={}, newSize={}, limit={})", - login, blockchainName, oldSize, blockBytes.length, newSize, limit); - return new AddBlockResult(413, "limit_exceeded", serverLastNum, serverLastHashHex); - } - } catch (Exception e) { - log.error("AddBlock: limit_check_failed (login={}, blockchainName={})", login, blockchainName, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "limit_check_failed", serverLastNum, serverLastHashHex); - } - - // 4) parse block - final BchBlockEntry block; - try { - block = new BchBlockEntry(blockBytes); - } catch (Exception e) { - log.warn("AddBlock: не удалось распарсить BchBlockEntry (login={}, blockchainName={}, bytesLen={})", - login, blockchainName, blockBytes.length, e); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_format", serverLastNum, serverLastHashHex); - } - - // body.check() - try { - block.body.check(); - } catch (Exception e) { - log.warn("AddBlock: body.check() не прошёл (login={}, blockchainName={}, blockNumber={}, type={}, ver={})", - login, blockchainName, block.blockNumber, (block.type & 0xFFFF), (block.version & 0xFFFF), e); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex); - } - - // 4.2) запрет дырок: blockNumber строго last+1 - int expectedBlockNumber = serverLastNum + 1; - if (block.blockNumber != expectedBlockNumber) { - log.warn("AddBlock: bad_block_number (login={}, blockchainName={}, пришёл={}, ожидали={}, serverLastNum={})", - login, blockchainName, block.blockNumber, expectedBlockNumber, serverLastNum); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_number", serverLastNum, serverLastHashHex); - } - - // (временная совместимость) req.globalNumber должен совпасть с block.blockNumber - if (globalNumberFromReq != block.blockNumber) { - log.warn("AddBlock: req_global_mismatch (login={}, blockchainName={}, reqGlobal={}, blockNumber={})", - login, blockchainName, globalNumberFromReq, block.blockNumber); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "req_global_mismatch", serverLastNum, serverLastHashHex); - } - - // 4.3) проверка цепочки по prevHash32 - if (!Arrays.equals(block.prevHash32, serverLastHash32)) { - log.warn("AddBlock: bad_prev_hash (login={}, blockchainName={}, blockNumber={}, clientPrev={}, serverPrev={})", - login, blockchainName, block.blockNumber, toHex(block.prevHash32), serverLastHashHex); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_prev_hash", serverLastNum, serverLastHashHex); - } - - // 5) pubKey - final byte[] pubKey32 = st.getBlockchainKeyBytes(); - if (pubKey32 == null || pubKey32.length != 32) { - log.warn("AddBlock: bad_blockchain_key_len (login={}, blockchainName={}, blockNumber={}, keyLen={})", - login, blockchainName, block.blockNumber, (pubKey32 == null ? -1 : pubKey32.length)); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex); - } - - // 6) подпись по hash32(preimage) - boolean sigOk; - try { - sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32); - } catch (Exception e) { - log.warn("AddBlock: signature_verify_failed (login={}, blockchainName={}, blockNumber={})", - login, blockchainName, block.blockNumber, e); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_signature", serverLastNum, serverLastHashHex); - } - - if (!sigOk) { - log.warn("AddBlock: bad_signature (login={}, blockchainName={}, blockNumber={})", - login, blockchainName, block.blockNumber); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_signature", serverLastNum, serverLastHashHex); - } - - // 7) line columns (only for BodyHasLine) - Integer lineCode = null; - Integer prevLineNumber = null; - byte[] prevLineHash32 = null; - Integer thisLineNumber = null; - - if (block.body instanceof BodyHasLine bl) { - lineCode = bl.lineCode(); - prevLineNumber = bl.prevLineBlockGlobalNumber(); - prevLineHash32 = bl.prevLineBlockHash32(); - thisLineNumber = bl.lineSeq(); - - // Нормализация: -1 не пишем в БД (для совместимости со старым TextBody) - if (prevLineNumber != null && prevLineNumber == -1) { - prevLineNumber = null; - prevLineHash32 = null; - thisLineNumber = null; - } - - // Если prevLineNumber задан — проверяем его хэш - if (prevLineNumber != null) { - try { - byte[] dbPrevHash = blocksDAO.getHashByNumber(blockchainName, prevLineNumber); - if (dbPrevHash == null) { - log.warn("AddBlock: prev_line_block_not_found (login={}, blockchainName={}, blockNumber={}, prevLineNumber={})", - login, blockchainName, block.blockNumber, prevLineNumber); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "prev_line_block_not_found", serverLastNum, serverLastHashHex); - } - if (!Arrays.equals(dbPrevHash, require32OrThrow(prevLineHash32, "prevLineHash32 invalid"))) { - log.warn("AddBlock: bad_prev_line_hash (login={}, blockchainName={}, blockNumber={}, prevLineNumber={})", - login, blockchainName, block.blockNumber, prevLineNumber); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_prev_line_hash", serverLastNum, serverLastHashHex); - } - } catch (Exception e) { - log.error("AddBlock: db_error_prev_line_check (login={}, blockchainName={}, blockNumber={})", - login, blockchainName, block.blockNumber, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "db_error_prev_line_check", serverLastNum, serverLastHashHex); - } - } - } - - // 8) сформировать запись и записать (DB + state + файл) - try { - BlockEntry be = new BlockEntry(); - be.setLogin(login); - be.setBchName(blockchainName); - - be.setBlockNumber(block.blockNumber); - be.setMsgType(block.type & 0xFFFF); - be.setMsgSubType(block.subType & 0xFFFF); - - be.setBlockBytes(block.toBytes()); - be.setBlockHash(block.getHash32()); - be.setBlockSignature(block.getSignature64()); - - // line columns (optional) - be.setLineCode(lineCode); - be.setPrevLineNumber(prevLineNumber); - be.setPrevLineHash(prevLineHash32); - be.setThisLineNumber(thisLineNumber); - - // target columns (optional) - if (block.body instanceof BodyHasTarget t) { - be.setToLogin(t.toLogin()); - be.setToBchName(t.toBchName()); - be.setToBlockNumber(t.toBlockGlobalNumber()); - be.setToBlockHash(t.toBlockHashBytes()); - } - - // edit helper (optional): если TEXT_EDIT_* — это "редактирование блока цели" - int type = block.type & 0xFFFF; - int sub = block.subType & 0xFFFF; - - if (type == 1 - && (sub == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || sub == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) - && be.getToBlockNumber() != null) { - be.setEditedByBlockNumber(be.getToBlockNumber()); - } - - dbWriter.appendBlockAndState(blockchainName, block, st, be); - - } catch (Exception e) { - log.error("AddBlock: внутренняя ошибка при записи блока (login={}, blockchainName={}, blockNumber={})", - login, blockchainName, block.blockNumber, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex); - } - - String newHashHex = toHex(block.getHash32()); - - log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}", - login, blockchainName, block.blockNumber, newHashHex); - - return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex); - } - - /* ===================================================================== */ - /* ====================== Helpers ====================================== */ - /* ===================================================================== */ - - private static byte[] decodeBase64(String b64) { - if (b64 == null) throw new IllegalArgumentException("blockBytesB64 == null"); - return Base64.getDecoder().decode(b64); - } - - private static long safeAdd(long a, long b) { - long r = a + b; - if (((a ^ r) & (b ^ r)) < 0) throw new ArithmeticException("long overflow"); - return r; - } - - private static byte[] require32OrThrow(byte[] b, String msg) { - if (b == null || b.length != 32) throw new IllegalArgumentException(msg); - return b; - } - - private static String toHex(byte[] bytes) { - if (bytes == null) return "null"; - char[] HEX = "0123456789abcdef".toCharArray(); - char[] out = new char[bytes.length * 2]; - for (int i = 0; i < bytes.length; i++) { - int v = bytes[i] & 0xFF; - out[i * 2] = HEX[v >>> 4]; - out[i * 2 + 1] = HEX[v & 0x0F]; - } - return new String(out); - } - - private static final class AddBlockResult { - final int httpStatus; - final String reasonCode; - final int serverLastBlockNumber; - final String serverLastBlockHashHex; - - AddBlockResult(int httpStatus, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) { - this.httpStatus = httpStatus; - this.reasonCode = reasonCode; - this.serverLastBlockNumber = serverLastBlockNumber; - this.serverLastBlockHashHex = serverLastBlockHashHex; - } - - boolean isOk() { return httpStatus == WireCodes.Status.OK; } - } -} -package server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils; - -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.ReentrantLock; - -public final class BlockchainLocks { - private static final ConcurrentHashMap MAP = new ConcurrentHashMap<>(); - - private BlockchainLocks() {} - - public static ReentrantLock lockFor(String blockchainName) { - return MAP.computeIfAbsent(blockchainName, id -> new ReentrantLock(true)); // fair=true - } -} -package server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils; - -import blockchain.BchBlockEntry; -import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.BlocksDAO; -import shine.db.entities.BlockchainStateEntry; -import shine.db.entities.BlockEntry; -import utils.files.FileStoreUtil; - -import java.sql.Connection; -import java.sql.SQLException; - -/** - * BlockchainWriter — запись блока в DB + обновление state + запись в файл. - * - * ВАЖНО: - * - Это минимальный рабочий вариант под новый формат. - * - Если у тебя уже есть "атомарность" сложнее (tmp_bch + commit/recovery) — можно усилить потом. - */ -public final class BlockchainWriter { - - private final BlocksDAO blocksDAO; - private final BlockchainStateDAO stateDAO; - private final FileStoreUtil fs = FileStoreUtil.getInstance(); - - public BlockchainWriter(BlocksDAO blocksDAO, BlockchainStateDAO stateDAO) { - this.blocksDAO = blocksDAO; - this.stateDAO = stateDAO; - } - - public void appendBlockAndState(String blockchainName, - BchBlockEntry block, - BlockchainStateEntry st, - BlockEntry be) throws SQLException { - - long nowMs = System.currentTimeMillis(); - - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) { - c.setAutoCommit(false); - try { - // 1) insert block - blocksDAO.insert(c, be); - - // 2) update state - st.setLastBlockNumber(block.blockNumber); - st.setLastBlockHash(block.getHash32()); - st.setFileSizeBytes(st.getFileSizeBytes() + block.toBytes().length); - st.setUpdatedAtMs(nowMs); - - stateDAO.upsert(c, st); - - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - if (e instanceof SQLException se) throw se; - throw new SQLException("appendBlockAndState failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } - - // 3) append to file (минимально: просто дописать) - // Если у тебя уже есть логика tmp_bch+atomicReplace — можно заменить тут. - String fileName = fs.buildBlockchainFileName(blockchainName); - fs.addDataToFile(fileName, block.toBytes()); - } -} -package server.logic.ws_protocol.JSON.handlers; - -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; - -/** - * Общий интерфейс для всех JSON-хэндлеров. - */ -public interface JsonMessageHandler { - - /** - * Обработать запрос и вернуть ответ. - * - * @param request распарсенный запрос - * @param ctx контекст текущего WebSocket-соединения - */ - Net_Response handle(Net_Request request, ConnectionContext ctx) throws Exception; -} - -package server.logic.ws_protocol.JSON.handlers.subscriptions.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос GetSubscribedChannels. - * - * Клиент отправляет: - * { - * "op": "GetSubscribedChannels", - * "requestId": "....", - * "payload": { - * "login": "anya" - * } - * } - */ -public class Net_GetSubscribedChannels_Request extends Net_Request { - - private String login; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } -} -package server.logic.ws_protocol.JSON.handlers.subscriptions.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.List; - -/** - * Ответ GetSubscribedChannels. - * - * payload: - * { - * "channels": [ - * { - * "channelLogin": "dima", - * "channelBchName": "dima-001", - * "publicationsCount": 123, - * "lastPublicationTimestampSec": 1736371200, - * "lastTextPreview": "...." - * } - * ] - * } - */ -public class Net_GetSubscribedChannels_Response extends Net_Response { - - private List channels; - - public List getChannels() { return channels; } - public void setChannels(List channels) { this.channels = channels; } - - public static class ChannelInfo { - - private String channelLogin; - private String channelBchName; - - private Integer publicationsCount; - - /** Unix seconds времени ПУБЛИКАЦИИ (оригинального TEXT_NEW). Nullable, если публикаций нет. */ - private Long lastPublicationTimestampSec; - - /** Первые 50 символов актуального текста (edit или orig). Nullable, если публикаций нет. */ - private String lastTextPreview; - - public String getChannelLogin() { return channelLogin; } - public void setChannelLogin(String channelLogin) { this.channelLogin = channelLogin; } - - public String getChannelBchName() { return channelBchName; } - public void setChannelBchName(String channelBchName) { this.channelBchName = channelBchName; } - - public Integer getPublicationsCount() { return publicationsCount; } - public void setPublicationsCount(Integer publicationsCount) { this.publicationsCount = publicationsCount; } - - public Long getLastPublicationTimestampSec() { return lastPublicationTimestampSec; } - public void setLastPublicationTimestampSec(Long lastPublicationTimestampSec) { this.lastPublicationTimestampSec = lastPublicationTimestampSec; } - - public String getLastTextPreview() { return lastTextPreview; } - public void setLastTextPreview(String lastTextPreview) { this.lastTextPreview = lastTextPreview; } - } -} -//package server.logic.ws_protocol.JSON.handlers.subscriptions; -// -//import blockchain.BchBlockEntry; -//import blockchain.body.TextBody; -//import org.slf4j.Logger; -//import org.slf4j.LoggerFactory; -//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.subscriptions.entyties.Net_GetSubscribedChannels_Request; -//import server.logic.ws_protocol.JSON.handlers.subscriptions.entyties.Net_GetSubscribedChannels_Response; -//import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -//import server.logic.ws_protocol.WireCodes; -//import shine.db.SqliteDbController; -//import shine.db.dao.SubscriptionsDAO; -// -//import java.sql.Connection; -//import java.sql.SQLException; -//import java.util.ArrayList; -//import java.util.List; -// -///** -// * Handler: GetSubscribedChannels -// * -// * Логика: -// * - DAO возвращает last publication orig bytes (+ edit bytes если есть) -// * - Handler парсит FULL bytes блока: -// * timestamp берём из ОРИГИНАЛА (publication) -// * текст берём из EDIT (если есть) иначе из оригинала -// * - формируем превью первых 50 символов -// */ -//public class Net_GetSubscribedChannels_Handler implements JsonMessageHandler { -// -// private static final Logger log = LoggerFactory.getLogger(Net_GetSubscribedChannels_Handler.class); -// -// @Override -// public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { -// Net_GetSubscribedChannels_Request req = (Net_GetSubscribedChannels_Request) baseRequest; -// -// if (req.getLogin() == null || req.getLogin().isBlank()) { -// return NetExceptionResponseFactory.error( -// req, -// WireCodes.Status.BAD_REQUEST, -// "BAD_FIELDS", -// "Некорректное поле: login" -// ); -// } -// -// // Если хочешь жёстче: -// // if (!req.getLogin().matches("^[A-Za-z0-9_]+$")) ... -// -// SubscriptionsDAO dao = SubscriptionsDAO.getInstance(); -// SqliteDbController db = SqliteDbController.getInstance(); -// -// try (Connection c = db.getConnection()) { -// -// List rows = dao.getSubscribedChannels(c, req.getLogin()); -// List out = new ArrayList<>(rows.size()); -// -// for (SubscriptionsDAO.ChannelRow r : rows) { -// Net_GetSubscribedChannels_Response.ChannelInfo dto = -// new Net_GetSubscribedChannels_Response.ChannelInfo(); -// -// dto.setChannelLogin(r.getChannelLogin()); -// dto.setChannelBchName(r.getChannelBchName()); -// dto.setPublicationsCount(r.getPublicationsCount()); -// -// byte[] pubBytes = r.getLastPublicationBlockBytes(); -// byte[] editBytes = r.getLastEditBlockBytes(); -// -// if (pubBytes == null || pubBytes.length == 0) { -// dto.setLastPublicationTimestampSec(null); -// dto.setLastTextPreview(null); -// out.add(dto); -// continue; -// } -// -// // 1) timestamp берём из ОРИГИНАЛЬНОЙ публикации -// BchBlockEntry pubBlock = new BchBlockEntry(pubBytes); -// dto.setLastPublicationTimestampSec(pubBlock.timestamp); -// -// // 2) текст — из EDIT (если есть) иначе из оригинала -// byte[] actualBytes = (editBytes != null && editBytes.length > 0) ? editBytes : pubBytes; -// BchBlockEntry actualBlock = new BchBlockEntry(actualBytes); -// -// if (!(actualBlock.body instanceof TextBody)) { -// // Это уже нарушение данных: last publication должен быть текстовым блоком. -// throw new IllegalStateException("Last publication is not TextBody: type=" -// + (actualBlock.body == null ? "null" : (actualBlock.body.type() & 0xFFFF))); -// } -// -// String msg = ((TextBody) actualBlock.body).message; -// dto.setLastTextPreview(firstNCharsSafe(msg, 50)); -// -// out.add(dto); -// } -// -// Net_GetSubscribedChannels_Response resp = new Net_GetSubscribedChannels_Response(); -// resp.setOp(req.getOp()); -// resp.setRequestId(req.getRequestId()); -// resp.setStatus(WireCodes.Status.OK); -// resp.setChannels(out); -// -// return resp; -// -// } catch (SQLException e) { -// log.error("❌ DB error GetSubscribedChannels", e); -// return NetExceptionResponseFactory.error( -// req, -// WireCodes.Status.SERVER_DATA_ERROR, -// "DB_ERROR", -// "Ошибка БД" -// ); -// } catch (IllegalArgumentException e) { -// // сюда попадёт, например, если BchBlockEntry не смог распарсить block_byte -// log.error("❌ Bad block bytes in DB (cannot parse BchBlockEntry)", e); -// return NetExceptionResponseFactory.error( -// req, -// WireCodes.Status.SERVER_DATA_ERROR, -// "BAD_BLOCK_BYTES", -// "В БД обнаружен повреждённый блок" -// ); -// } catch (Exception e) { -// log.error("❌ Internal error GetSubscribedChannels", e); -// return NetExceptionResponseFactory.error( -// req, -// WireCodes.Status.INTERNAL_ERROR, -// "INTERNAL_ERROR", -// "Внутренняя ошибка сервера" -// ); -// } -// } -// -// /** -// * Берём первые N "символов" безопасно для emoji/суррогатных пар: -// * режем по code points. -// */ -// private static String firstNCharsSafe(String s, int n) { -// if (s == null) return null; -// if (n <= 0) return ""; -// int cp = s.codePointCount(0, s.length()); -// if (cp <= n) return s; -// int end = s.offsetByCodePoints(0, n); -// return s.substring(0, end); -// } -//} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос AddUser — временная/тестовая регистрация локального пользователя. - * - * Клиент отправляет: - * - * { - * "op": "AddUser", - * "requestId": "test-add-1", - * "payload": { - * "login": "anya", - * "blockchainName": "anya-001", - * "solanaKey": "base64-ed25519-public-key-login", - * "blockchainKey": "base64-ed25519-public-key-blockchain", - * "deviceKey": "base64-ed25519-public-key-device", - * "bchLimit": 1000000 - * } - * } - * - * Все поля лежат внутри payload. - */ -public class Net_AddUser_Request extends Net_Request { - - private String login; - private String blockchainName; - - /** Ключ пользователя Solana (публичный ключ логина) */ - private String solanaKey; - - /** Ключ блокчейна (публичный ключ блокчейна) */ - private String blockchainKey; - - /** Ключ устройства (публичный ключ устройства) */ - private String deviceKey; - - private Integer bchLimit; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getBlockchainName() { return blockchainName; } - public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } - - public String getSolanaKey() { return solanaKey; } - public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; } - - public String getBlockchainKey() { return blockchainKey; } - public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } - - public String getDeviceKey() { return deviceKey; } - public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } - - public Integer getBchLimit() { return bchLimit; } - public void setBchLimit(Integer bchLimit) { this.bchLimit = bchLimit; } -} -// file: server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Response.java -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Успешный ответ на AddUser. - * - * Сейчас дополнительных полей нет — достаточно status=200. - * - * Пример: - * { - * "op": "AddUser", - * "requestId": "test-add-1", - * "status": 200, - * "payload": { } - * } - */ -public class Net_AddUser_Response extends Net_Response { - // При необходимости сюда можно добавить, например, флаг created/updated и т.п. -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос GetUser — проверка/получение пользователя по login. - * - * Клиент отправляет: - * - * { - * "op": "GetUser", - * "requestId": "u-1", - * "payload": { - * "login": "AnYa" - * } - * } - * - * Поиск по login выполняется без учёта регистра. - * В ответе возвращаем login/blockchainName с тем регистром, как в БД. - */ -public class Net_GetUser_Request extends Net_Request { - - private String login; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ GetUser. - * - * Всегда status=200. - * - * Пример (нет пользователя): - * { - * "op": "GetUser", - * "requestId": "u-1", - * "status": 200, - * "payload": { "exists": false } - * } - * - * Пример (есть пользователь): - * { - * "op": "GetUser", - * "requestId": "u-1", - * "status": 200, - * "payload": { - * "exists": true, - * "login": "Anya", - * "blockchainName": "anya-001", - * "solanaKey": "...", - * "blockchainKey": "...", - * "deviceKey": "..." - * } - * } - */ -public class Net_GetUser_Response extends Net_Response { - - private Boolean exists; - - private String login; - private String blockchainName; - private String solanaKey; - private String blockchainKey; - private String deviceKey; - - public Boolean getExists() { return exists; } - public void setExists(Boolean exists) { this.exists = exists; } - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getBlockchainName() { return blockchainName; } - public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } - - public String getSolanaKey() { return solanaKey; } - public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; } - - public String getBlockchainKey() { return blockchainKey; } - public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } - - public String getDeviceKey() { return deviceKey; } - public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос SearchUsers — поиск логинов по префиксу. - * - * Клиент отправляет: - * { - * "op": "SearchUsers", - * "requestId": "su-1", - * "payload": { "prefix": "any" } - * } - * - * Поиск по prefix выполняется без учёта регистра. - * В ответе возвращаем логины с тем регистром, как в БД. - */ -public class Net_SearchUsers_Request extends Net_Request { - - private String prefix; - - public String getPrefix() { return prefix; } - public void setPrefix(String prefix) { this.prefix = prefix; } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.ArrayList; -import java.util.List; - -/** - * Ответ SearchUsers. - * - * Всегда status=200. - * - * Пример: - * { - * "op": "SearchUsers", - * "requestId": "su-1", - * "status": 200, - * "payload": { - * "logins": ["Anya", "andrew", "Angel"] - * } - * } - */ -public class Net_SearchUsers_Response extends Net_Response { - - private List logins = new ArrayList<>(); - - public List getLogins() { return logins; } - public void setLogins(List logins) { this.logins = logins; } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.tempToTest.entyties.Net_AddUser_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.BlockchainStateEntry; -import shine.db.entities.SolanaUserEntry; -import utils.blockchain.BlockchainNameUtil; - -import java.sql.Connection; -import java.sql.SQLException; -import java.util.Base64; - -public class Net_AddUser_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_AddUser_Handler.class); - - /** TEST ONLY */ - private static final int TEST_BCH_LIMIT = 1_000_000; - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_AddUser_Request req = (Net_AddUser_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank() - || req.getBlockchainName() == null || req.getBlockchainName().isBlank() - || req.getSolanaKey() == null || req.getSolanaKey().isBlank() - || req.getBlockchainKey() == null || req.getBlockchainKey().isBlank() - || req.getDeviceKey() == null || req.getDeviceKey().isBlank()) { - - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login/blockchainName/solanaKey/blockchainKey/deviceKey" - ); - } - - // blockchainName должен быть вида: -NNN - if (!BlockchainNameUtil.isBlockchainNameMatchesLogin(req.getBlockchainName(), req.getLogin())) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BLOCKCHAIN_NAME", - "blockchainName должен быть вида -NNN (пример: anya-001)" - ); - } - - int limit = (req.getBchLimit() == null || req.getBchLimit() <= 0) - ? TEST_BCH_LIMIT - : req.getBchLimit(); - - try { - // базовая валидация форматов ключей: Base64(32 bytes) - byte[] solanaKey32 = Base64.getDecoder().decode(req.getSolanaKey()); - if (solanaKey32.length != 32) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_SOLANA_KEY", - "solanaKey должен быть Base64(32 bytes)" - ); - } - - byte[] blockchainKey32 = Base64.getDecoder().decode(req.getBlockchainKey()); - if (blockchainKey32.length != 32) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BLOCKCHAIN_KEY", - "blockchainKey должен быть Base64(32 bytes)" - ); - } - - byte[] deviceKey32 = Base64.getDecoder().decode(req.getDeviceKey()); - if (deviceKey32.length != 32) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_DEVICE_KEY", - "deviceKey должен быть Base64(32 bytes)" - ); - } - - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); - BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); - - SqliteDbController db = SqliteDbController.getInstance(); - - try (Connection c = db.getConnection()) { - c.setAutoCommit(false); - - // 1. Проверяем, что пользователя нет (case-insensitive) - if (usersDAO.getByLogin(c, req.getLogin()) != null) { - return NetExceptionResponseFactory.error( - req, - 409, - "USER_ALREADY_EXISTS", - "Пользователь с таким login уже существует" - ); - } - - // 2. Проверяем, что blockchainName ещё нет (case-sensitive, как в БД) - if (usersDAO.existsByBlockchainName(c, req.getBlockchainName())) { - return NetExceptionResponseFactory.error( - req, - 409, - "BLOCKCHAIN_ALREADY_EXISTS", - "Пользователь с таким blockchainName уже существует" - ); - } - - // 3. На всякий случай оставляем старую проверку blockchain_state, - // потому что эта таблица нужна серверу (состояние цепочки/лимиты). - if (stateDAO.getByBlockchainName(c, req.getBlockchainName()) != null) { - return NetExceptionResponseFactory.error( - req, - 409, - "BLOCKCHAIN_STATE_ALREADY_EXISTS", - "blockchain_state уже существует" - ); - } - - // 4. Создаём пользователя (все поля теперь лежат в solana_users) - SolanaUserEntry user = new SolanaUserEntry(); - user.setLogin(req.getLogin()); - user.setBlockchainName(req.getBlockchainName()); - user.setSolanaKey(req.getSolanaKey()); - user.setBlockchainKey(req.getBlockchainKey()); - user.setDeviceKey(req.getDeviceKey()); - - usersDAO.insert(c, user); - - // 5. Создаём INITIAL blockchain_state (для работы сервера) - BlockchainStateEntry st = new BlockchainStateEntry(); - st.setBlockchainName(req.getBlockchainName()); - st.setLogin(req.getLogin()); - st.setBlockchainKey(req.getBlockchainKey()); // Base64(32) - st.setLastBlockNumber(-1); - st.setLastBlockHash(new byte[32]); - st.setFileSizeBytes(0); - st.setSizeLimit(limit); - st.setUpdatedAtMs(System.currentTimeMillis()); - - stateDAO.upsert(c, st); - - c.commit(); - } - - Net_AddUser_Response resp = new Net_AddUser_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - log.info("✅ AddUser ok: login={}, blockchainName={}, limit={}", - req.getLogin(), req.getBlockchainName(), limit); - - return resp; - - } catch (IllegalArgumentException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_KEY_FORMAT", - e.getMessage() - ); - } catch (SQLException e) { - log.error("❌ DB error AddUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error AddUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.tempToTest.entyties.Net_GetUser_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; - -import java.sql.SQLException; - -public class Net_GetUser_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_GetUser_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_GetUser_Request req = (Net_GetUser_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank()) { - // тут логичнее BAD_REQUEST, но ты просил: "нет пользователя" тоже 200. - // Поэтому BAD_REQUEST оставляем только на реально пустой login. - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login" - ); - } - - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); - - try { - SolanaUserEntry u = usersDAO.getByLogin(req.getLogin()); - - Net_GetUser_Response resp = new Net_GetUser_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - if (u == null) { - resp.setExists(false); - log.info("ℹ️ GetUser: not found for login={}", req.getLogin()); - return resp; - } - - // ВАЖНО: - // - Поиск по login был case-insensitive, - // - а тут возвращаем login/blockchainName как в БД (с исходным регистром). - resp.setExists(true); - resp.setLogin(u.getLogin()); - resp.setBlockchainName(u.getBlockchainName()); - resp.setSolanaKey(u.getSolanaKey()); - resp.setBlockchainKey(u.getBlockchainKey()); - resp.setDeviceKey(u.getDeviceKey()); - - log.info("✅ GetUser: found login={}, blockchainName={}", u.getLogin(), u.getBlockchainName()); - return resp; - - } catch (SQLException e) { - log.error("❌ DB error GetUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error GetUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.tempToTest.entyties.Net_SearchUsers_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_SearchUsers_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; - -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.List; - -public class Net_SearchUsers_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_SearchUsers_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_SearchUsers_Request req = (Net_SearchUsers_Request) baseRequest; - - if (req.getPrefix() == null || req.getPrefix().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: prefix" - ); - } - - String prefix = req.getPrefix().trim(); - - try { - SolanaUsersDAO dao = SolanaUsersDAO.getInstance(); - List users = dao.searchByLoginPrefix(prefix); // case-insensitive + LIMIT 5 - - List logins = new ArrayList<>(); - for (SolanaUserEntry u : users) { - if (u != null && u.getLogin() != null) { - logins.add(u.getLogin()); // регистр как в БД - } - } - - Net_SearchUsers_Response resp = new Net_SearchUsers_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setLogins(logins); - - log.info("✅ SearchUsers ok: prefix='{}' -> {}", prefix, logins.size()); - return resp; - - } catch (SQLException e) { - log.error("❌ DB error SearchUsers", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error SearchUsers", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос GetUserParam — получить один параметр пользователя. - * - * { - * "op": "GetUserParam", - * "requestId": "req-1", - * "payload": { - * "login": "anya", - * "param": "feed:lastSeenGlobal" - * } - * } - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) этот запрос не ограничивает просмотр параметров, т.к. проект в тестовом режиме. - * Позже, вероятно, потребуется ограничить: кто и какие параметры может читать (сессия/права). - * Но для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_GetUserParam_Request extends Net_Request { - - private String login; - private String param; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ GetUserParam. - * - * Если найден: - * { - * "op": "GetUserParam", - * "requestId": "req-1", - * "status": 200, - * "payload": { - * "login": "anya", - * "param": "feed:lastSeenGlobal", - * "time_ms": 1736000000123, - * "value": "105", - * "device_key": "base64-32", - * "signature": "base64-64" - * } - * } - * - * Если не найден: - * status=404, payload пустой. - */ -public class Net_GetUserParam_Response extends Net_Response { - - private String login; - private String param; - private Long time_ms; - private String value; - private String device_key; - private String signature; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } - - public Long getTime_ms() { return time_ms; } - public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } - - public String getValue() { return value; } - public void setValue(String value) { this.value = value; } - - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос ListUserParams — получить все сохранённые параметры пользователя. - * - * { - * "op": "ListUserParams", - * "requestId": "req-2", - * "payload": { - * "login": "anya" - * } - * } - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) запрос не ограничивает просмотр параметров. - * В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры. - * Для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_ListUserParams_Request extends Net_Request { - - private String login; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.ArrayList; -import java.util.List; - -/** - * Ответ ListUserParams — список всех параметров пользователя. - * - * { - * "op": "ListUserParams", - * "requestId": "req-2", - * "status": 200, - * "payload": { - * "login": "anya", - * "params": [ - * { - * "login": "anya", - * "param": "feed:lastSeenGlobal", - * "time_ms": 1736000000123, - * "value": "105", - * "device_key": "base64-32", - * "signature": "base64-64" - * }, - * ... - * ] - * } - * } - */ -public class Net_ListUserParams_Response extends Net_Response { - - private String login; - private List params = new ArrayList<>(); - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public List getParams() { return params; } - public void setParams(List params) { this.params = params; } - - public static class Item { - private String login; - private String param; - private Long time_ms; - private String value; - private String device_key; - private String signature; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } - - public Long getTime_ms() { return time_ms; } - public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } - - public String getValue() { return value; } - public void setValue(String value) { this.value = value; } - - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос UpsertUserParam — добавить/обновить сохранённый параметр пользователя. - * - * Клиент отправляет: - * - * { - * "op": "UpsertUserParam", - * "requestId": "req-123", - * "payload": { - * "login": "anya", - * "param": "feed:lastSeenGlobal", - * "time_ms": 1736000000123, - * "value": "105", - * "device_key": "base64-ed25519-public-key-32", - * "signature": "base64-ed25519-signature-64" - * } - * } - * - * Подпись считается от UTF-8 строки: - * USER_PARAMETER_PREFIX + login + param + time_ms + value - */ -public class Net_UpsertUserParam_Request extends Net_Request { - - private String login; - private String param; - private Long time_ms; - private String value; - - private String device_key; - private String signature; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } - - public Long getTime_ms() { return time_ms; } - public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } - - public String getValue() { return value; } - public void setValue(String value) { this.value = value; } - - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на UpsertUserParam. - * - * Успех: - * { - * "op": "UpsertUserParam", - * "requestId": "req-123", - * "status": 200, - * "payload": { } - * } - */ -public class Net_UpsertUserParam_Response extends Net_Response { - // MVP: без payload. При желании позже можно добавить created/updated. -} -package server.logic.ws_protocol.JSON.handlers.userParams; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.userParams.entyties.Net_GetUserParam_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.UserParamsDAO; -import shine.db.entities.UserParamEntry; - -import java.sql.Connection; - -/** - * GetUserParam — получить один параметр пользователя. - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) запрос не ограничивает просмотр параметров. - * В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры. - * Для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_GetUserParam_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_GetUserParam_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_GetUserParam_Request req = (Net_GetUserParam_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank() - || req.getParam() == null || req.getParam().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login/param" - ); - } - - String login = req.getLogin().trim(); - String param = req.getParam().trim(); - - try { - SqliteDbController db = SqliteDbController.getInstance(); - UserParamsDAO dao = UserParamsDAO.getInstance(); - - try (Connection c = db.getConnection()) { - UserParamEntry e = dao.getByLoginAndParam(c, login, param); - - if (e == null) { - Net_GetUserParam_Response resp = new Net_GetUserParam_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(404); - return resp; - } - - Net_GetUserParam_Response resp = new Net_GetUserParam_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - resp.setLogin(e.getLogin()); - resp.setParam(e.getParam()); - resp.setTime_ms(e.getTimeMs()); - resp.setValue(e.getValue()); - resp.setDevice_key(e.getDeviceKey()); - resp.setSignature(e.getSignature()); - - return resp; - } - - } catch (Exception e) { - log.error("❌ Internal error GetUserParam", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.userParams.entyties.Net_ListUserParams_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.UserParamsDAO; -import shine.db.entities.UserParamEntry; - -import java.sql.Connection; -import java.util.ArrayList; -import java.util.List; - -/** - * ListUserParams — получить все параметры пользователя. - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) запрос не ограничивает просмотр параметров. - * В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры. - * Для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_ListUserParams_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_ListUserParams_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_ListUserParams_Request req = (Net_ListUserParams_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login" - ); - } - - String login = req.getLogin().trim(); - - try { - SqliteDbController db = SqliteDbController.getInstance(); - UserParamsDAO dao = UserParamsDAO.getInstance(); - - List entries; - try (Connection c = db.getConnection()) { - entries = dao.getByLogin(c, login); - } - - Net_ListUserParams_Response resp = new Net_ListUserParams_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - resp.setLogin(login); - - List items = new ArrayList<>(); - for (UserParamEntry e : entries) { - Net_ListUserParams_Response.Item it = new Net_ListUserParams_Response.Item(); - it.setLogin(e.getLogin()); - it.setParam(e.getParam()); - it.setTime_ms(e.getTimeMs()); - it.setValue(e.getValue()); - it.setDevice_key(e.getDeviceKey()); - it.setSignature(e.getSignature()); - items.add(it); - } - resp.setParams(items); - - return resp; - - } catch (Exception e) { - log.error("❌ Internal error ListUserParams", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.userParams.entyties.Net_UpsertUserParam_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.SolanaUsersDAO; -import shine.db.dao.UserParamsDAO; -import shine.db.entities.SolanaUserEntry; -import shine.db.entities.UserParamEntry; -import utils.config.ShineSignatureConstants; -import utils.crypto.Ed25519Util; - -import java.nio.charset.StandardCharsets; -import java.sql.Connection; -import java.sql.SQLException; -import java.util.Base64; - -/** - * Net_UpsertUserParam_Handler - * - * Делает (MVP, без "сессий"): - * 1) Проверка входных полей. - * 2) Проверка подписи Ed25519 по device_key. - * 3) Проверка, что пользователь существует и что device_key принадлежит этому login. - * 4) Атомарная запись в БД "только если time_ms новее" (UPSERT + WHERE). - * - * ВАЖНО: - * - НИКАКИХ ручных транзакций / BEGIN здесь нет. - * - autoCommit=true, каждый statement завершённый сам по себе. - * - Гонки не страшны: если за время проверок кто-то записал более новый time_ms, - * наш финальный UPSERT просто вернёт 0 обновлённых строк. - */ -public class Net_UpsertUserParam_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_UpsertUserParam_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_UpsertUserParam_Request req = (Net_UpsertUserParam_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank() - || req.getParam() == null || req.getParam().isBlank() - || req.getTime_ms() == null || req.getTime_ms() <= 0 - || req.getValue() == null - || req.getDevice_key() == null || req.getDevice_key().isBlank() - || req.getSignature() == null || req.getSignature().isBlank()) { - - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login/param/time_ms/value/device_key/signature" - ); - } - - final String login = req.getLogin().trim(); - final String param = req.getParam().trim(); - final long timeMs = req.getTime_ms(); - final String value = req.getValue(); - final String deviceKeyB64 = req.getDevice_key().trim(); - final String signatureB64 = req.getSignature().trim(); - - try { - // ---------------- Base64 decode ---------------- - byte[] pubKey32; - byte[] sig64; - try { - pubKey32 = Base64.getDecoder().decode(deviceKeyB64); - sig64 = Base64.getDecoder().decode(signatureB64); - } catch (IllegalArgumentException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "device_key/signature должны быть Base64" - ); - } - - if (pubKey32.length != 32) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_DEVICE_KEY", - "device_key должен быть Base64(32 bytes)" - ); - } - if (sig64.length != 64) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_SIGNATURE", - "signature должна быть Base64(64 bytes)" - ); - } - - // ---------------- Signature verify ---------------- - String signText = ShineSignatureConstants.USER_PARAMETER_PREFIX - + login - + param - + timeMs - + value; - - byte[] signBytes = signText.getBytes(StandardCharsets.UTF_8); - - boolean sigOk = Ed25519Util.verify(signBytes, sig64, pubKey32); - if (!sigOk) { - return NetExceptionResponseFactory.error( - req, - 403, - "SIGNATURE_INVALID", - "Подпись не прошла проверку" - ); - } - - // ---------------- DB checks + upsert ---------------- - SqliteDbController db = SqliteDbController.getInstance(); - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); - UserParamsDAO paramsDAO = UserParamsDAO.getInstance(); - - try (Connection c = db.getConnection()) { - // 1) user exists - SolanaUserEntry user = usersDAO.getByLogin(c, login); - if (user == null) { - return NetExceptionResponseFactory.error( - req, - 404, - "USER_NOT_FOUND", - "Пользователь не найден" - ); - } - - // 2) device key must match the user's stored deviceKey - String userDeviceKey = user.getDeviceKey(); - if (userDeviceKey == null || userDeviceKey.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "USER_DEVICE_KEY_EMPTY", - "У пользователя не задан deviceKey в БД" - ); - } - - if (!userDeviceKey.trim().equals(deviceKeyB64)) { - return NetExceptionResponseFactory.error( - req, - 403, - "DEVICE_KEY_MISMATCH", - "device_key не соответствует пользователю" - ); - } - - // 3) atomic upsert-if-newer - UserParamEntry e = new UserParamEntry( - login, - param, - timeMs, - value, - deviceKeyB64, - signatureB64 - ); - - int changed = paramsDAO.upsertIfNewer(c, e); - - Net_UpsertUserParam_Response resp = new Net_UpsertUserParam_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - if (changed == 1) { - log.info("✅ UpsertUserParam applied: login={}, param={}, time_ms={}", login, param, timeMs); - } else { - // 0 строк — значит в БД уже есть time_ms >= incoming - log.info("ℹ️ UpsertUserParam ignored (not newer): login={}, param={}, time_ms={}", login, param, timeMs); - } - - return resp; - } - - } catch (SQLException e) { - log.error("❌ DB error UpsertUserParam", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error UpsertUserParam", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; -import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; - -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; - -// --- 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; - -// --- 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; - -// --- NEW v2 entities --- -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.blockchain.Net_AddBlock_Handler; -import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; - -import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request; - -import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request; - -// --- NEW: SearchUsers --- -import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_SearchUsers_Request; - -import server.logic.ws_protocol.JSON.handlers.userParams.Net_GetUserParam_Handler; -import server.logic.ws_protocol.JSON.handlers.userParams.Net_ListUserParams_Handler; -import server.logic.ws_protocol.JSON.handlers.userParams.Net_UpsertUserParam_Handler; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Request; - -// !!! подставь реальные пакеты/имена, как у тебя в проекте: -//import server.logic.ws_protocol.JSON.handlers.subscriptions.Net_GetSubscribedChannels_Handler; -import server.logic.ws_protocol.JSON.handlers.subscriptions.entyties.Net_GetSubscribedChannels_Request; - -import java.util.Map; - -/** - * JsonHandlerRegistry — единое место, где руками регистрируются - * JSON-операции: op → handler и op → requestClass. - */ -public final class JsonHandlerRegistry { - - // Map.of(...) поддерживает максимум 10 пар => используем Map.ofEntries(...) - private static final Map HANDLERS = Map.ofEntries( - Map.entry("AddUser", new Net_AddUser_Handler()), - Map.entry("GetUser", new Net_GetUser_Handler()), - Map.entry("SearchUsers", new Net_SearchUsers_Handler()), - - // --- auth --- - Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()), - Map.entry("CreateAuthSession", new Net_CreateAuthSession__Handler()), - Map.entry("CloseActiveSession", new Net_CloseActiveSession_Handler()), - Map.entry("ListSessions", new Net_ListSessions_Handler()), - - // --- login to existing session in 2 steps --- - Map.entry("SessionChallenge", new Net_SessionChallenge_Handler()), - Map.entry("SessionLogin", new Net_SessionLogin_Handler()), - - // --- blockchain --- - Map.entry("AddBlock", new Net_AddBlock_Handler()), - - // --- userParams --- - Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()), - Map.entry("GetUserParam", new Net_GetUserParam_Handler()), - Map.entry("ListUserParams", new Net_ListUserParams_Handler()) - - // --- subscriptions --- -// Map.entry("ListSubscribedChannels", new Net_GetSubscribedChannels_Handler()) - ); - - private static final Map> REQUEST_TYPES = Map.ofEntries( - Map.entry("AddUser", Net_AddUser_Request.class), - Map.entry("GetUser", Net_GetUser_Request.class), - Map.entry("SearchUsers", Net_SearchUsers_Request.class), - - // --- auth --- - Map.entry("AuthChallenge", Net_AuthChallenge_Request.class), - Map.entry("CreateAuthSession", Net_CreateAuthSession_Request.class), - Map.entry("CloseActiveSession", Net_CloseActiveSession_Request.class), - Map.entry("ListSessions", Net_ListSessions_Request.class), - - // --- NEW v2 --- - Map.entry("SessionChallenge", Net_SessionChallenge_Request.class), - Map.entry("SessionLogin", Net_SessionLogin_Request.class), - - // --- blockchain --- - Map.entry("AddBlock", Net_AddBlock_Request.class), - - // --- userParams --- - Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class), - Map.entry("GetUserParam", Net_GetUserParam_Request.class), - Map.entry("ListUserParams", Net_ListUserParams_Request.class), - - // --- subscriptions --- - Map.entry("ListSubscribedChannels", Net_GetSubscribedChannels_Request.class) - ); - - private JsonHandlerRegistry() { } - - public static Map getHandlers() { - return HANDLERS; - } - - public static Map> getRequestTypes() { - return REQUEST_TYPES; - } -} -package server.logic.ws_protocol.JSON; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.entyties.Net_Exception_Response; -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.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; - -import java.util.Map; - -/** - * JsonInboundProcessor — обработка JSON-сообщений. - * - * 1) Парсит общий пакет (op, requestId, payload). - * 2) По op выбирает класс запроса и хэндлер. - * 3) Собирает "плоский" объект: op + requestId + поля из payload. - * 4) Маппит его в NetRequest через ObjectMapper. - * 5) Вызывает хэндлер, получает NetResponse. - * 6) Собирает JSON-ответ: - * { - * "op": ..., - * "requestId": ..., - * "status": ..., - * "payload": { все поля response, кроме op/requestId/status/payload } - * } - */ -public final class JsonInboundProcessor { - - private static final Logger log = LoggerFactory.getLogger(JsonInboundProcessor.class); - - private static final ObjectMapper JSON_MAPPER = new ObjectMapper() - .setSerializationInclusion(JsonInclude.Include.NON_NULL); - - private static final Map JSON_HANDLERS = - JsonHandlerRegistry.getHandlers(); - - private static final Map> JSON_REQUEST_TYPES = - JsonHandlerRegistry.getRequestTypes(); - - private JsonInboundProcessor() { - // utility - } - - public static String processJson(String json, ConnectionContext ctx) { - String op = null; - String requestId = null; - - // Для лога полезно знать, кто прислал (хотя бы login/sessionId, если есть) - String ctxLogin = safe(ctx != null ? ctx.getLogin() : null); - String ctxSessionId = safe(ctx != null ? ctx.getSessionId() : null); - - try { - if (json == null || json.isBlank()) { - Net_Exception_Response err = NetExceptionResponseFactory.error( - null, - null, - WireCodes.Status.BAD_REQUEST, - "EMPTY_JSON", - "Пустое JSON-сообщение" - ); - - String out = writeResponse(err); - - // DEBUG: что пришло / что ушло - if (log.isDebugEnabled()) { - log.debug("JSON IN (login={}, sessionId={}): ", ctxLogin, ctxSessionId); - log.debug("JSON OUT (login={}, sessionId={}): {}", ctxLogin, ctxSessionId, shorten(out, 1200)); - } - return out; - } - - // DEBUG: сырой вход (обрезаем, чтобы не убить лог) - if (log.isDebugEnabled()) { - log.debug("JSON IN (login={}, sessionId={}): {}", ctxLogin, ctxSessionId, shorten(json, 1200)); - } - - // 1) Парсим общий пакет - JsonNode root = JSON_MAPPER.readTree(json); - - // 2) op и requestId из корня - op = getTextOrNull(root, "op"); - requestId = getTextOrNull(root, "requestId"); - - if (op == null || op.isEmpty()) { - Net_Exception_Response err = NetExceptionResponseFactory.error( - null, - requestId, - WireCodes.Status.BAD_REQUEST, - "NO_OP", - "Поле 'op' отсутствует или пустое" - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - - JsonMessageHandler handler = JSON_HANDLERS.get(op); - Class reqClass = JSON_REQUEST_TYPES.get(op); - - if (handler == null || reqClass == null) { - Net_Exception_Response err = NetExceptionResponseFactory.error( - op, - requestId, - WireCodes.Status.BAD_REQUEST, - "UNKNOWN_OP", - "Неизвестная операция: " + op - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - - // 3) Берём payload - JsonNode payloadNode = root.get("payload"); - if (payloadNode == null || payloadNode.isNull()) { - Net_Exception_Response err = NetExceptionResponseFactory.error( - op, - requestId, - WireCodes.Status.BAD_REQUEST, - "NO_PAYLOAD", - "Поле 'payload' отсутствует" - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - if (!payloadNode.isObject()) { - Net_Exception_Response err = NetExceptionResponseFactory.error( - op, - requestId, - WireCodes.Status.BAD_REQUEST, - "BAD_PAYLOAD", - "Поле 'payload' должно быть объектом" - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - - // 3.1 Собираем "плоский" объект для маппинга в NetRequest: - // op + requestId + поля из payload - ObjectNode merged = JSON_MAPPER.createObjectNode(); - - // Добавляем op и requestId, чтобы они попали в NetRequest - merged.put("op", op); - if (requestId != null) merged.put("requestId", requestId); - - // Добавляем все поля из payload внутрь - merged.setAll((ObjectNode) payloadNode); - - // 4) Маппим в конкретный класс NetRequest - Net_Request request; - try { - request = JSON_MAPPER.treeToValue(merged, reqClass); - } catch (Exception mapErr) { - // Важно: вот это часто “теряется”, если не логировать отдельно - log.error("❌ JSON map error (op={}, requestId={}, login={}, sessionId={}): merged={}", - op, safe(requestId), ctxLogin, ctxSessionId, shorten(merged.toString(), 1200), mapErr); - - Net_Exception_Response err = NetExceptionResponseFactory.error( - op, - requestId, - WireCodes.Status.BAD_REQUEST, - "BAD_REQUEST_FORMAT", - "Некорректный формат запроса: не удалось распарсить поля payload" - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - - // DEBUG: нормализованный запрос (уже распарсен) - if (log.isDebugEnabled()) { - log.debug("REQ OBJ (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(safeToString(request), 1200)); - } - - // 5) Вызываем хэндлер - Net_Response response; - try { - response = handler.handle(request, ctx); - } catch (Exception handlerError) { - // ✅ Вот тут как раз и должны “появляться ошибки в логере” - log.error("💥 Handler error (op={}, requestId={}, login={}, sessionId={})", - op, safe(requestId), ctxLogin, ctxSessionId, handlerError); - - Net_Exception_Response err = NetExceptionResponseFactory.error( - op, - requestId, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_HANDLER_ERROR", - "Неожиданная ошибка при обработке операции: " + op - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - - // На всякий случай: если хэндлер не выставил op/requestId - if (response.getOp() == null) response.setOp(op); - if (response.getRequestId() == null) response.setRequestId(requestId); - - // 6) Универсальная сборка ответа - String out = writeResponse(response); - - // DEBUG: ответ ушёл - if (log.isDebugEnabled()) { - log.debug("RESP OBJ (login={}, sessionId={}, op={}, requestId={}, status={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), response.getStatus(), shorten(safeToString(response), 1200)); - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}, status={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), response.getStatus(), shorten(out, 1200)); - } - - return out; - - } catch (Exception e) { - // ✅ Любая неожиданная ошибка парсинга/обработки — в лог - log.error("❌ JSON processing error (op={}, requestId={}, login={}, sessionId={})", - safe(op), safe(requestId), safe(ctxLogin), safe(ctxSessionId), e); - - Net_Exception_Response err = NetExceptionResponseFactory.error( - op != null ? op : "Unknown", - requestId, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - - String out = writeResponse(err); - - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - - return out; - } - } - - // --- helpers --- - - private static String getTextOrNull(JsonNode node, String field) { - if (node == null || !node.has(field) || node.get(field).isNull()) return null; - return node.get(field).asText(); - } - - /** - * Унифицированная сериализация любого NetResponse в формат: - * { - * "op": ..., - * "requestId": ..., - * "status": ..., - * "payload": { ... } - * } - */ - private static String writeResponse(Net_Response response) { - try { - // Конвертируем полный объект ответа в ObjectNode - ObjectNode full = JSON_MAPPER.convertValue(response, ObjectNode.class); - - // То, что должно остаться наверху: - String op = full.hasNonNull("op") ? full.get("op").asText() : null; - String requestId = full.hasNonNull("requestId") ? full.get("requestId").asText() : null; - int status = full.hasNonNull("status") ? full.get("status").asInt() : 0; - - // Удаляем базовые поля и payload из "полного" объекта, - // всё остальное отправляем внутрь payload. - full.remove("op"); - full.remove("requestId"); - full.remove("status"); - full.remove("payload"); - - ObjectNode root = JSON_MAPPER.createObjectNode(); - if (op != null) root.put("op", op); else root.putNull("op"); - if (requestId != null) root.put("requestId", requestId); else root.putNull("requestId"); - root.put("status", status); - - // payload — это всё, что осталось от full (может быть пустым объектом {}) - root.set("payload", full); - - return JSON_MAPPER.writeValueAsString(root); - - } catch (Exception e) { - // Совсем аварийный случай — сериализация ответа сломалась. - log.error("❌ Response serialization error (op={}, requestId={})", - safe(response != null ? response.getOp() : null), - safe(response != null ? response.getRequestId() : null), - e); - - return "{\"op\":\"" + safe(response != null ? response.getOp() : null) + - "\",\"requestId\":\"" + safe(response != null ? response.getRequestId() : null) + - "\",\"status\":" + (response != null ? response.getStatus() : 500) + - ",\"payload\":{\"code\":\"SERIALIZATION_ERROR\",\"message\":\"Ошибка сериализации ответа\"}}"; - } - } - - private static String safe(String s) { - return s != null ? s : ""; - } - - private static String shorten(String s, int max) { - if (s == null) return ""; - if (s.length() <= max) return s; - return s.substring(0, Math.max(0, max)) + "...(+" + (s.length() - max) + " chars)"; - } - - private static String safeToString(Object o) { - if (o == null) return "null"; - try { - // Чтобы не плодить огромные логи и не утыкаться в циклические ссылки — - // логируем как JSON, если возможно. - return JSON_MAPPER.writeValueAsString(o); - } catch (Exception ignore) { - return String.valueOf(o); - } - } -} -package server.logic.ws_protocol.JSON.utils; - -import shine.db.entities.SolanaUserEntry; -import utils.crypto.Ed25519Util; - -import java.nio.charset.StandardCharsets; -import java.util.Base64; - -public final class AuthSignatures { - - private AuthSignatures() {} - - /** preimage для CreateAuthSession(v2): "AUTH_CREATE_SESSION:login:timeMs:authNonce" */ - public static byte[] preimageCreateAuthSession(String login, long timeMs, String authNonce) { - String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce; - return preimageStr.getBytes(StandardCharsets.UTF_8); - } - - /** Декод base64 / base64url (если надо — подстрой под твой decodeBase64Any) */ - public static byte[] decodeBase64Any(String s) throws IllegalArgumentException { - if (s == null) throw new IllegalArgumentException("base64 is null"); - String x = s.trim(); - if (x.isEmpty()) throw new IllegalArgumentException("base64 is empty"); - - try { - return Base64.getDecoder().decode(x); - } catch (IllegalArgumentException e1) { - // пробуем base64url без паддинга - return Base64.getUrlDecoder().decode(x); - } - } - - /** - * Проверка подписи CreateAuthSession(v2) по deviceKey пользователя. - * Подпись проверяется над preimageCreateAuthSession(...). - */ - public static boolean verifyCreateAuthSessionSignature( - SolanaUserEntry user, - String login, - String authNonce, - long timeMs, - String signatureB64 - ) throws IllegalArgumentException { - - // user.getDeviceKey() — base64 публичного ключа (32 байта) - byte[] publicKey32 = decodeBase64Any(user.getDeviceKey()); - byte[] signature64 = decodeBase64Any(signatureB64); - - byte[] preimage = preimageCreateAuthSession(login, timeMs, authNonce); - return Ed25519Util.verify(preimage, signature64, publicKey32); - } -} -package server.logic.ws_protocol.JSON.utils; - -import server.logic.ws_protocol.JSON.entyties.Net_Exception_Response; -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Фабрика ошибок для JSON-протокола. - * Создаёт единообразные NetExceptionResponse. - */ -public final class NetExceptionResponseFactory { - - private NetExceptionResponseFactory() { - // запрет на создание объектов - } - - public static Net_Exception_Response error(Net_Request req, - int status, - String code, - String message) { - - Net_Exception_Response resp = new Net_Exception_Response(); - - // ✅ НЕ падаем, даже если req == null - if (req != null) { - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - } else { - resp.setOp(null); - resp.setRequestId(null); - } - - resp.setStatus(status); - resp.setCode(code); - resp.setMessage(message); - return resp; - } - - /** - * Вариант для случаев, когда NetRequest ещё не распарсен, - * но мы уже знаем op и requestId (или они null). - */ - public static Net_Exception_Response error(String op, - String requestId, - int status, - String code, - String message) { - - Net_Exception_Response resp = new Net_Exception_Response(); - resp.setOp(op); - resp.setRequestId(requestId); - resp.setStatus(status); - resp.setCode(code); - resp.setMessage(message); - return resp; - } -} -package server.logic.ws_protocol; - -/** - * WireCodes — константы бинарного протокола поверх WebSocket. - *. - * Формат входящего сообщения: - * [4] int opCode (big-endian) - * [*] payload - *. - * Ответ сервера: - * ровно [4] int statusCode (big-endian) - */ -public final class WireCodes { - private WireCodes() {} - - public static final class Op { - public static final int PING = 0; - public static final int ADD_BLOCK = 1; - public static final int GET_BLOCKCHAIN = 2; - public static final int SEARCH_USERS = 30; - public static final int GET_LAST_BLOCK_INFO = 31; - private Op() {} - } - - public static final class Status { - public static final int PONG = 100; // ответ на PING -// public static final int OK = 200; // успех - - public static final int ALREADY_EXISTS = 409; // пришёл блок < N+1 - public static final int NON_SEQUENTIAL = 412; // пришёл блок > N+1 - public static final int NOT_FOUND = 422; // Нет такого полбзователя - типо добавляем блок к которому нет пользователя - хотя на деле такой статус наверное никогда не вернётся, тк это раньше проверяется - - - private Status() {} - - - - - // ============================================================ - // 🟢 УСПЕШНЫЕ ОПЕРАЦИИ - // ============================================================ - - /** ✅ Блок успешно добавлен в цепочку. */ - public static final int OK = 200; - - /** 🌱 Создана новая цепочка (первый блок-заголовок принят). */ - public static final int CHAIN_CREATED = 201; - - /** - * 🔁 Такой блок уже существует. - * Клиент может считать это успешным ответом: - * - сервер возвращает 8 байт: [4] код (202) + [4] номер последнего блока (int) - * - клиент обновляет свой lastBlockNumber и не пересылает этот блок снова. */ - public static final int BLOCK_ALREADY_EXISTS = 202; // плюс к кодуследом возвращается номер последнего блока на сервере - - - // ============================================================ - // 🟡 ЛОГИЧЕСКИЕ / ПРОТОКОЛЬНЫЕ ОШИБКИ - // ============================================================ - - /** ⚠️ Нарушена последовательность — пришёл блок с номером > ожидаемого. - * Сервер вернёт 8 байт: [4] код (409) + [4] последний номер блока. - * Клиент должен дослать недостающие блоки. */ - public static final int OUT_OF_SEQUENCE = 409; // плюс к кодуследом возвращается номер последнего блока на сервере - - /** ❌ Некорректные или неполные данные в запросе. */ - public static final int BAD_REQUEST = 400; - - /** 🚫 Цепочка с указанным blockchainId не найдена. */ - public static final int CHAIN_NOT_FOUND = 404; - - /** 🧩 Несовпадение blockchainId между заголовком блока и телом. */ - public static final int INVALID_BLOCKCHAIN_ID = 421; - - /** ❌ Ошибка верификации блока — хэш или подпись не совпали. - * 🔐 Ошибка хэша: SHA-256(preimage) не совпал с переданным hash32. - * 🔏 Ошибка подписи Ed25519 — блок не прошёл криптографическую проверку. */ - public static final int UNVERIFIED = 422; - - - /** 🙅 Некорректный логин (пустой, неверный формат, недопустимые символы). По сути вообще не может быть, тк логин проверяют при создании в другом блокчейне*/ - public static final int BAD_LOGIN = 462; - - - // ============================================================ - // 🔴 СИСТЕМНЫЕ ОШИБКИ / ОГРАНИЧЕНИЯ - // ============================================================ - - // ============================================================ - // 🔴 СИСТЕМНЫЕ ОШИБКИ / ОГРАНИЧЕНИЯ - // ============================================================ - - /** 💾 Достигнут лимит размера блокчейна. */ - public static final int BLOCKCHAIN_FULL = 507; - - /** 🧱 Ошибка при сохранении или обновлении данных на сервере (файлы, JSON и т.п.). */ - public static final int SERVER_DATA_ERROR = 501; - - /** 💥 Общая внутренняя ошибка сервера (необработанное исключение). */ - public static final int INTERNAL_ERROR = 500; - } - -} - -package server.ws; - -import org.eclipse.jetty.websocket.api.Session; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -import server.logic.ws_protocol.JSON.ConnectionContext; -import shine.db.entities.SolanaUserEntry; - -import java.net.SocketAddress; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Утилита для работы с WebSocket-подключениями. - * - * Цель этой версии: - * - всегда логировать "кто закрыл" / "что закрывали" / "в каком состоянии был WS"; - * - логировать исключения так, чтобы было видно первопричину; - * - не терять контекст из-за ctx.reset() (сначала снимаем "снимок" полей). - */ -public final class WsConnectionUtils { - - private static final Logger log = LoggerFactory.getLogger(WsConnectionUtils.class); - - /** Счётчик событий закрытия (удобно коррелировать логи). */ - private static final AtomicLong CLOSE_SEQ = new AtomicLong(0); - - private WsConnectionUtils() { - // utility - } - - public static void closeConnection(ConnectionContext ctx, int statusCode, String reason) { - closeConnection(ctx, statusCode, reason, null, "UNKNOWN"); - } - - /** - * Расширенное закрытие с указанием инициатора и причины (Throwable). - * - * @param ctx контекст - * @param statusCode код закрытия - * @param reason причина (пойдёт в close frame + логи) - * @param cause исключение/первопричина (если закрываем из catch) - * @param initiator строка "кто инициировал" (handler/op/requestId/etc.) - */ - public static void closeConnection(ConnectionContext ctx, - int statusCode, - String reason, - Throwable cause, - String initiator) { - if (ctx == null) return; - - final long closeId = CLOSE_SEQ.incrementAndGet(); - - // --- СНИМОК КОНТЕКСТА ДО reset() --- - final Session ws = ctx.getWsSession(); - - final String sessionId = safeString(ctx.getSessionId()); - final int authStatus = safeAuthStatus(ctx); - - final SolanaUserEntry user = ctx.getSolanaUser(); - final String login = (user != null ? safeString(user.getLogin()) : ""); - - final String activeSessionId = - (ctx.getActiveSession() != null ? safeString(ctx.getActiveSession().getSessionId()) : ""); - - final boolean wsPresent = (ws != null); - final boolean wsOpen = (ws != null && safeIsOpen(ws)); - final String wsInfo = formatWsInfo(ws); - - final String threadName = Thread.currentThread().getName(); - final int ctxId = System.identityHashCode(ctx); - - // Логируем "начало закрытия" всегда, чтобы видеть даже случаи "ws уже закрыт" - if (cause != null) { - log.warn("WS_CLOSE#{} BEGIN initiator={} thread={} ctxId={} login={} sessionId={} activeSessionId={} authStatus={} statusCode={} reason={} wsPresent={} wsOpen={} wsInfo={}", - closeId, initiator, threadName, ctxId, login, sessionId, activeSessionId, authStatus, statusCode, reason, wsPresent, wsOpen, wsInfo, cause); - } else { - log.info("WS_CLOSE#{} BEGIN initiator={} thread={} ctxId={} login={} sessionId={} activeSessionId={} authStatus={} statusCode={} reason={} wsPresent={} wsOpen={} wsInfo={}", - closeId, initiator, threadName, ctxId, login, sessionId, activeSessionId, authStatus, statusCode, reason, wsPresent, wsOpen, wsInfo); - } - - // --- ШАГ 1: убрать из реестра (чтобы новые сообщения не шли в мёртвый контекст) --- - try { - ActiveConnectionsRegistry.getInstance().remove(ctx); - log.debug("WS_CLOSE#{} registry.remove OK ctxId={} sessionId={} login={}", closeId, ctxId, sessionId, login); - } catch (Exception e) { - log.warn("WS_CLOSE#{} registry.remove FAIL ctxId={} sessionId={} login={}", closeId, ctxId, sessionId, login, e); - } - - // --- ШАГ 2: закрыть WS (если открыт) --- - if (ws != null) { - if (safeIsOpen(ws)) { - try { - ws.close(statusCode, safeString(reason)); - log.info("WS_CLOSE#{} ws.close OK ctxId={} sessionId={} login={} statusCode={} reason={}", - closeId, ctxId, sessionId, login, statusCode, reason); - } catch (Exception e) { - log.warn("WS_CLOSE#{} ws.close FAIL ctxId={} sessionId={} login={} statusCode={} reason={} wsInfo={}", - closeId, ctxId, sessionId, login, statusCode, reason, wsInfo, e); - } - } else { - log.info("WS_CLOSE#{} ws already closed ctxId={} sessionId={} login={} wsInfo={}", - closeId, ctxId, sessionId, login, wsInfo); - } - } - - // --- ШАГ 3: очистить контекст (в конце, чтобы не потерять поля в логах выше) --- - try { - ctx.reset(); - log.debug("WS_CLOSE#{} ctx.reset OK ctxId={} (was sessionId={}, login={})", closeId, ctxId, sessionId, login); - } catch (Exception e) { - log.warn("WS_CLOSE#{} ctx.reset FAIL ctxId={} (was sessionId={}, login={})", closeId, ctxId, sessionId, login, e); - } - - log.info("WS_CLOSE#{} END initiator={} ctxId={} sessionId={} login={}", closeId, initiator, ctxId, sessionId, login); - } - - private static String safeString(String s) { - return (s == null ? "" : s); - } - - private static int safeAuthStatus(ConnectionContext ctx) { - try { - return ctx.getAuthenticationStatus(); - } catch (Exception e) { - return -999; - } - } - - private static boolean safeIsOpen(Session ws) { - try { - return ws.isOpen(); - } catch (Exception e) { - return false; - } - } - - private static String formatWsInfo(Session ws) { - if (ws == null) return "null"; - - String remote = ""; - String local = ""; - try { - SocketAddress ra = ws.getRemoteAddress(); - remote = (ra != null ? ra.toString() : ""); - } catch (Exception ignored) { } - - try { - SocketAddress la = ws.getLocalAddress(); - local = (la != null ? la.toString() : ""); - } catch (Exception ignored) { } - - return "remote=" + remote + ", local=" + local; - } -} diff --git a/SHiNE-server/shine-server-net-protocol/concat_to_file.sh b/SHiNE-server/shine-server-net-protocol/concat_to_file.sh deleted file mode 100755 index f6db1f1..0000000 --- a/SHiNE-server/shine-server-net-protocol/concat_to_file.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# собрать только *.java файлы и вывести их содержимое в файл -find . -type f -name "*.java" | sort | while read -r f; do - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Все .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/all_files.txt b/SHiNE-server/shine-server-net-protocol/src/main/java/server/all_files.txt deleted file mode 100644 index cce7330..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/all_files.txt +++ /dev/null @@ -1,4742 +0,0 @@ -// file: server/logic/ws_protocol/B64.java -package server.logic.ws_protocol; - -import java.util.Base64; - -/** - * Единая утилита Base64 для всего WS-протокола. - * - * Правило: используем ТОЛЬКО стандартный Base64 (RFC 4648): - * - алфавит: A-Z a-z 0-9 + / - * - padding: "=" (Java encoder добавляет по умолчанию) - * - * Никаких Base64url ("-" "_") и никаких "без padding" в протоколе. - */ -public final class B64 { - - private B64() {} - - /** Кодирует байты в стандартный Base64 (с padding). */ - public static String enc(byte[] bytes) { - if (bytes == null) throw new IllegalArgumentException("bytes == null"); - return Base64.getEncoder().encodeToString(bytes); - } - - /** Декодирует стандартный Base64 в байты. */ - public static byte[] dec(String b64) { - if (b64 == null) throw new IllegalArgumentException("base64 == null"); - String s = b64.trim(); - if (s.isEmpty()) throw new IllegalArgumentException("base64 == empty"); - // Строго стандартный декодер (не url-safe) - return Base64.getDecoder().decode(s); - } - - /** Декодирует и проверяет, что длина результата ровно expectedLen. */ - public static byte[] decLen(String b64, int expectedLen, String fieldName) { - byte[] out = dec(b64); - if (out.length != expectedLen) { - throw new IllegalArgumentException(fieldName + " must decode to " + expectedLen + " bytes, got " + out.length); - } - return out; - } - - public static byte[] dec32(String b64, String fieldName) { - return decLen(b64, 32, fieldName); - } - - public static byte[] dec64(String b64, String fieldName) { - return decLen(b64, 64, fieldName); - } -} -package server.logic.ws_protocol; - -import java.util.Base64; - -/** - * Единая утилита Base64 для всего WS-протокола. - * - * ВАЖНО: - * - Используем ТОЛЬКО стандартный Base64 (RFC 4648) алфавит: '+' и '/'. - * - Без padding '=' (чтобы строки были короче и стабильнее для JSON). - * - Декодер при этом спокойно принимает и с '=' и без '='. - */ -public final class Base64Ws { - - private static final Base64.Encoder ENC = Base64.getEncoder().withoutPadding(); - private static final Base64.Decoder DEC = Base64.getDecoder(); - - private Base64Ws() {} - - public static String encode(byte[] bytes) { - if (bytes == null) throw new IllegalArgumentException("bytes == null"); - return ENC.encodeToString(bytes); - } - - public static byte[] decode(String b64) throws IllegalArgumentException { - if (b64 == null) throw new IllegalArgumentException("base64 is null"); - String s = b64.trim(); - if (s.isEmpty()) throw new IllegalArgumentException("base64 is empty"); - return DEC.decode(s); - } - - public static byte[] decodeLen(String b64, int expectedLen, String fieldName) throws IllegalArgumentException { - byte[] v = decode(b64); - if (v.length != expectedLen) { - String f = (fieldName == null || fieldName.isBlank()) ? "value" : fieldName; - throw new IllegalArgumentException(f + " must be " + expectedLen + " bytes, got " + v.length); - } - return v; - } -} -package server.logic.ws_protocol.JSON; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArraySet; - -/** - * Реестр активных подключений (только авторизованные). - */ -public final class ActiveConnectionsRegistry { - - private static final Logger log = LoggerFactory.getLogger(ActiveConnectionsRegistry.class); - - private static final ActiveConnectionsRegistry INSTANCE = new ActiveConnectionsRegistry(); - - public static ActiveConnectionsRegistry getInstance() { - return INSTANCE; - } - - private ActiveConnectionsRegistry() { - // singleton - } - - // sessionId (String) -> ConnectionContext - private final ConcurrentHashMap bySessionId = new ConcurrentHashMap<>(); - - // login (String) -> множество ConnectionContext для этого пользователя - private final ConcurrentHashMap> byLogin = new ConcurrentHashMap<>(); - - /** - * Зарегистрировать авторизованное подключение. - * Ожидается, что в ctx уже выставлены login и sessionId. - */ - public void register(ConnectionContext ctx) { - if (ctx == null) return; - - String sessionId = ctx.getSessionId(); - String login = ctx.getLogin(); - - if (sessionId == null || sessionId.isBlank() || login == null || login.isBlank()) { - log.debug("register skipped: bad ctx fields (login='{}', sessionId='{}')", login, sessionId); - return; - } - - // ✅ Если кто-то перерегистрировал тот же sessionId — вычищаем старый ctx из byLogin - ConnectionContext prev = bySessionId.put(sessionId, ctx); - if (prev != null && prev != ctx) { - String prevLogin = prev.getLogin(); - if (prevLogin != null && !prevLogin.isBlank()) { - Set prevSet = byLogin.get(prevLogin); - if (prevSet != null) { - prevSet.remove(prev); - if (prevSet.isEmpty()) { - byLogin.remove(prevLogin); - } - } - } - log.warn("sessionId reused: replaced previous ctx (sessionId={}, prevLogin={}, newLogin={})", - sessionId, prevLogin, login); - } - - byLogin - .computeIfAbsent(login, id -> new CopyOnWriteArraySet<>()) - .add(ctx); - - log.debug("registered ctx (login={}, sessionId={})", login, sessionId); - } - - /** - * Удалить подключение по контексту (например, при onClose). - */ - public void remove(ConnectionContext ctx) { - if (ctx == null) return; - - String sessionId = ctx.getSessionId(); - String login = ctx.getLogin(); - - if (sessionId != null && !sessionId.isBlank()) { - ConnectionContext removed = bySessionId.remove(sessionId); - - // Если в мапе лежал другой ctx под тем же sessionId — не трогаем его byLogin - if (removed != null && removed != ctx) { - log.debug("remove(ctx): sessionId mapped to another ctx, skip byLogin cleanup (sessionId={})", sessionId); - return; - } - } - - if (login != null && !login.isBlank()) { - Set set = byLogin.get(login); - if (set != null) { - set.remove(ctx); - if (set.isEmpty()) { - byLogin.remove(login); - } - } - } - - log.debug("removed ctx (login={}, sessionId={})", login, sessionId); - } - - /** - * Удалить подключение по sessionId. - */ - public void removeBySessionId(String sessionId) { - if (sessionId == null || sessionId.isBlank()) return; - - ConnectionContext ctx = bySessionId.remove(sessionId); - if (ctx == null) return; - - String login = ctx.getLogin(); - if (login != null && !login.isBlank()) { - Set set = byLogin.get(login); - if (set != null) { - set.remove(ctx); - if (set.isEmpty()) { - byLogin.remove(login); - } - } - } - - log.debug("removed by sessionId (login={}, sessionId={})", login, sessionId); - } - - /** - * Получить контекст по sessionId. - */ - public ConnectionContext getBySessionId(String sessionId) { - if (sessionId == null || sessionId.isBlank()) return null; - return bySessionId.get(sessionId); - } - - /** - * Получить все активные подключения пользователя по login. - */ - public Set getByLogin(String login) { - if (login == null || login.isBlank()) return Set.of(); - Set set = byLogin.get(login); - return (set == null) ? Set.of() : set; // CopyOnWriteArraySet можно отдавать как есть - } -} -package server.logic.ws_protocol.JSON; - -import org.eclipse.jetty.websocket.api.Session; -import shine.db.entities.SolanaUserEntry; -import shine.db.entities.ActiveSessionEntry; - -/** - * ConnectionContext — контекст состояния одного WebSocket-соединения. - * Живёт ровно столько же, сколько живёт подключение. - * - * Важно (v2): - * - Авторизация всегда 2 шага: - * A) Создание новой сессии через deviceKey: - * AuthChallenge(login) -> ctx.authNonce - * CreateAuthSession(...) -> ctx.AUTH_STATUS_USER + ctx.activeSession - * - * B) Вход в существующую сессию через sessionKey: - * SessionChallenge(sessionId) -> ctx.sessionLoginNonce + ctx.sessionLoginSessionId + expiresAt - * SessionLogin(...) -> проверка подписи sessionKey по pubkey из БД -> ctx.AUTH_STATUS_USER - */ -public class ConnectionContext { - - // Статусы аутентификации - public static final int AUTH_STATUS_NONE = 0; // анонимный / не авторизован - public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // выполнен challenge (AuthChallenge или SessionChallenge) - public static final int AUTH_STATUS_USER = 2; // авторизованный пользователь - - // Полный пользователь из БД (solana_users) - private SolanaUserEntry solanaUserEntry; - - // Активная сессия из БД (active_sessions) - private ActiveSessionEntry activeSessionEntry; - - /** - * Идентификатор сессии — base64-строка от 32 байт. - * Заполняется после успешного входа (AUTH_STATUS_USER). - */ - private String sessionId; - - /** - * Одноразовый nonce, выданный на шаге 1 (AuthChallenge), - * используется на шаге CreateAuthSession для проверки подписи deviceKey. - */ - private String authNonce; - - /* ===================== SessionLogin challenge (v2) ===================== */ - - /** - * Одноразовый nonce, выданный на шаге SessionChallenge(sessionId), - * используется на шаге SessionLogin для проверки подписи sessionKey. - */ - private String sessionLoginNonce; - - /** - * sessionId, для которого был выдан sessionLoginNonce. - * Нужен, чтобы SessionLogin не мог "подставить" другой sessionId. - */ - private String sessionLoginSessionId; - - /** - * Время истечения sessionLoginNonce (мс с 1970-01-01). - * Если текущее время > expiresAt, то nonce считается недействительным. - */ - private long sessionLoginNonceExpiresAtMs; - - /* ====================================================================== */ - - /** - * Текущий статус аутентификации. - * См. константы AUTH_STATUS_* - */ - private int authenticationStatus = AUTH_STATUS_NONE; - - /** - * WebSocket-сессия Jetty для данного подключения. - * Нужна, чтобы через ConnectionContext можно было отправлять сообщения клиенту. - */ - private Session wsSession; - - // --- WebSocket Session --- - - public Session getWsSession() { - return wsSession; - } - - public void setWsSession(Session wsSession) { - this.wsSession = wsSession; - } - - // --- SolanaUser / ActiveSession --- - - public SolanaUserEntry getSolanaUser() { - return solanaUserEntry; - } - - public void setSolanaUser(SolanaUserEntry solanaUserEntry) { - this.solanaUserEntry = solanaUserEntry; - } - - public ActiveSessionEntry getActiveSession() { - return activeSessionEntry; - } - - public void setActiveSession(ActiveSessionEntry activeSessionEntry) { - this.activeSessionEntry = activeSessionEntry; - } - - // --- Удобный геттер для логина --- - - public String getLogin() { - return solanaUserEntry != null ? solanaUserEntry.getLogin() : null; - } - - // --- sessionId --- - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } - - // --- authNonce --- - - public String getAuthNonce() { - return authNonce; - } - - public void setAuthNonce(String authNonce) { - this.authNonce = authNonce; - } - - // --- sessionLoginNonce (v2) --- - - public String getSessionLoginNonce() { - return sessionLoginNonce; - } - - public void setSessionLoginNonce(String sessionLoginNonce) { - this.sessionLoginNonce = sessionLoginNonce; - } - - public String getSessionLoginSessionId() { - return sessionLoginSessionId; - } - - public void setSessionLoginSessionId(String sessionLoginSessionId) { - this.sessionLoginSessionId = sessionLoginSessionId; - } - - public long getSessionLoginNonceExpiresAtMs() { - return sessionLoginNonceExpiresAtMs; - } - - public void setSessionLoginNonceExpiresAtMs(long sessionLoginNonceExpiresAtMs) { - this.sessionLoginNonceExpiresAtMs = sessionLoginNonceExpiresAtMs; - } - - // --- auth status --- - - public int getAuthenticationStatus() { - return authenticationStatus; - } - - public void setAuthenticationStatus(int authenticationStatus) { - this.authenticationStatus = authenticationStatus; - } - - public boolean isAuthenticatedUser() { - return authenticationStatus == AUTH_STATUS_USER; - } - - public boolean isAnonymous() { - return authenticationStatus == AUTH_STATUS_NONE; - } - - public void reset() { - solanaUserEntry = null; - activeSessionEntry = null; - - sessionId = null; - authNonce = null; - - sessionLoginNonce = null; - sessionLoginSessionId = null; - sessionLoginNonceExpiresAtMs = 0; - - authenticationStatus = AUTH_STATUS_NONE; - wsSession = null; - } - - @Override - public String toString() { - return "ConnectionContext{" + - "login='" + getLogin() + '\'' + - ", sessionId=" + sessionId + - ", authenticationStatus=" + authenticationStatus + - '}'; - } -} -package server.logic.ws_protocol.JSON.entyties; - -/** - * Базовый класс для всех событий (event). - * Общие поля: op и payload. - *. - * Формат JSON (event): - * { - * "op": "...", - * "payload": { ... } - * } - */ -public abstract class Net_Event { - - /** Имя операции / события (op). */ - private String op; - - /** - * Произвольные данные. - * В JSON это поле "payload". - */ - private Object payload; - - // --- getters / setters --- - - public String getOp() { - return op; - } - - public void setOp(String op) { - this.op = op; - } - - public Object getPayload() { - return payload; - } - - public void setPayload(Object payload) { - this.payload = payload; - } -} - -package server.logic.ws_protocol.JSON.entyties; - -/** - * Ответ с ошибкой (любой отказ). - *. - * В payload будет: - * { - * "code": "...", - * "message": "..." - * } - */ -public class Net_Exception_Response extends Net_Response { - - private String code; - private String message; - - public String getCode() { - return code; - } - - public void setCode(String code) { - this.code = code; - } - - public String getMessage() { - return message; - } - - public void setMessage(String message) { - this.message = message; - } -} - -package server.logic.ws_protocol.JSON.entyties; - -/** - * Базовый класс для всех запросов (client → server). - *. - * Наследуется от NetEvent и добавляет requestId. - *. - * Формат JSON (request): - * { - * "op": "...", - * "requestId": "...", - * "payload": { ... } - * } - */ -public abstract class Net_Request extends Net_Event { - - /** Идентификатор запроса, чтобы связать запрос и ответ. */ - private String requestId; - - // --- getters / setters --- - - public String getRequestId() { - return requestId; - } - - public void setRequestId(String requestId) { - this.requestId = requestId; - } -} - -package server.logic.ws_protocol.JSON.entyties; - -/** - * Базовый класс для всех ответов (server → client). - *. - * Наследуется от NetRequest и добавляет status. - *. - * Формат JSON (response): - * { - * "op": "...", - * "requestId": "...", - * "status": 200, - * "payload": { ... } // и для успеха, и для ошибки - * } - */ -public abstract class Net_Response extends Net_Request { - - /** Статус результата (200 — успех, любое другое значение — ошибка). */ - private int status; - - // --- getters / setters --- - - public int getStatus() { - return status; - } - - public void setStatus(int status) { - this.status = status; - } - - public boolean isOk() { - return status == 200; - } -} - -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 1 авторизации: запрос выдачи одноразового nonce (authNonce). - * - * Клиент по логину просит сервер сгенерировать случайный authNonce, - * который будет использован на втором шаге при подписи. - * - * Формат входящего JSON: - * { - * "op": "AuthChallenge", - * "requestId": "...", - * "payload": { - * "login": "someLogin" - * } - * } - * - * Формат успешного ответа: - * { - * "op": "AuthChallenge", - * "requestId": "...", - * "status": 200, - * "payload": { - * "authNonce": "base64-строка-от-32-байт" - * } - * } - */ -public class Net_AuthChallenge_Request extends Net_Request { - - /** - * Логин пользователя, для которого запускается авторизация. - */ - private String login; - - public String getLogin() { - return login; - } - public void setLogin(String login) { - this.login = login; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на AuthChallenge. - * - * При успехе сервер возвращает одноразовый nonce для подписи (authNonce), - * который клиент обязан использовать на втором шаге при формировании строки - * для цифровой подписи. - * - * JSON: - * { - * "op": "AuthChallenge", - * "requestId": "...", - * "status": 200, - * "payload": { - * "authNonce": "base64-строка-от-32-байт" - * } - * } - */ -public class Net_AuthChallenge_Response extends Net_Response { - - /** - * Одноразовый nonce для авторификации. - * Строка — это base64-представление 32 случайных байт. - */ - private String authNonce; - - public String getAuthNonce() { - return authNonce; - } - - public void setAuthNonce(String authNonce) { - this.authNonce = authNonce; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос CloseActiveSession — закрытие активной сессии пользователя. - * - * Новая логика (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Никаких подписей и "AUTH_IN_PROGRESS" здесь больше нет. - * - * payload: - * { - * "sessionId": "..." // опционально; если пусто — закрываем текущую - * } - */ -public class Net_CloseActiveSession_Request extends Net_Request { - - /** Идентификатор сессии, которую нужно закрыть. Может быть пустым. */ - private String sessionId; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на CloseActiveSession. - * - * При успехе: - * - status = 200; - * - payload = {}. - * - * Закрытие WebSocket-соединения может быть выполнено сразу (для другой сессии) - * или чуть позже (для текущей сессии) после отправки ответа. - */ -public class Net_CloseActiveSession_Response extends Net_Response { - // Дополнительных полей пока не требуется. -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 2 (v2): создание новой сессии ТОЛЬКО через deviceKey. - * - * Шаги: - * 1) AuthChallenge(login) -> authNonce - * 2) CreateAuthSession(storagePwd, sessionPubKeyB64, timeMs, signatureB64, clientInfo) - * - * Подпись deviceKey делается над строкой (UTF-8): - * AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce}:{sessionPubKeyB64}:{storagePwd} - * - * Важно: - * - sessionKey генерируется на клиенте, на сервер отправляется ТОЛЬКО sessionPubKeyB64 (32 bytes base64). - * - В БД active_sessions.session_key хранится sessionPubKeyB64. - */ -public class Net_CreateAuthSession_Request extends Net_Request { - - /** Клиентский пароль для хранения данных (base64 от 32 байт). */ - private String storagePwd; - - /** Публичный ключ сессии (sessionPubKey), base64 от 32 байт. */ - private String sessionPubKeyB64; - - /** Время на стороне клиента (мс с 1970-01-01). */ - private long timeMs; - - /** Подпись Ed25519(deviceKey) над строкой AUTH_CREATE_SESSION:... (base64). */ - private String signatureB64; - - /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ - private String clientInfo; - - public String getStoragePwd() { - return storagePwd; - } - - public void setStoragePwd(String storagePwd) { - this.storagePwd = storagePwd; - } - - public String getSessionPubKeyB64() { - return sessionPubKeyB64; - } - - public void setSessionPubKeyB64(String sessionPubKeyB64) { - this.sessionPubKeyB64 = sessionPubKeyB64; - } - - public long getTimeMs() { - return timeMs; - } - - public void setTimeMs(long timeMs) { - this.timeMs = timeMs; - } - - public String getSignatureB64() { - return signatureB64; - } - - public void setSignatureB64(String signatureB64) { - this.signatureB64 = signatureB64; - } - - public String getClientInfo() { - return clientInfo; - } - - public void setClientInfo(String clientInfo) { - this.clientInfo = clientInfo; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на CreateAuthSession (v2). - * - * При успехе сервер создаёт запись в active_sessions - * и возвращает идентификатор сессии sessionId. - * - * JSON: - * { - * "op": "CreateAuthSession", - * "requestId": "...", - * "status": 200, - * "payload": { - * "sessionId": "base64(32)" - * } - * } - */ -public class Net_CreateAuthSession_Response extends Net_Response { - - /** Идентификатор сессии, base64 от 32 байт. */ - private String sessionId; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос ListSessions — список активных сессий пользователя. - * - * Новая логика (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Пустой payload. - */ -public class Net_ListSessions_Request extends Net_Request { - // пусто -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.List; - -/** - * Ответ на ListSessions. - * - * При успехе: - * - status = 200; - * - payload: - * { - * "sessions": [ - * { - * "sessionId": "...", - * "clientInfoFromClient": "...", - * "clientInfoFromRequest": "...", - * "geo": "Country, City" | "unknown", - * "lastAuthirificatedAtMs": 1733310000000 - * }, - * ... - * ] - * } - */ -public class Net_ListSessions_Response extends Net_Response { - - /** - * Список активных сессий для текущего пользователя. - */ - private List sessions; - - public List getSessions() { - return sessions; - } - - public void setSessions(List sessions) { - this.sessions = sessions; - } - - /** - * Описание одной активной сессии. - */ - public static class SessionInfo { - - /** Идентификатор сессии, base64 от 32 байт. */ - private String sessionId; - - /** Что прислал клиент в CreateAuthSession/RefreshSession (clientInfo). */ - private String clientInfoFromClient; - - /** Краткая строка, собранная сервером из HTTP-запроса (UA, платформа и т.п.). */ - private String clientInfoFromRequest; - - /** Строка геолокации вида "Country, City" или "unknown". */ - private String geo; - - /** Время последней успешной авторизации/refresh (мс с 1970-01-01). */ - private long lastAuthirificatedAtMs; - - // --- getters / setters --- - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } - - public String getClientInfoFromClient() { - return clientInfoFromClient; - } - - public void setClientInfoFromClient(String clientInfoFromClient) { - this.clientInfoFromClient = clientInfoFromClient; - } - - public String getClientInfoFromRequest() { - return clientInfoFromRequest; - } - - public void setClientInfoFromRequest(String clientInfoFromRequest) { - this.clientInfoFromRequest = clientInfoFromRequest; - } - - public String getGeo() { - return geo; - } - - public void setGeo(String geo) { - this.geo = geo; - } - - public long getLastAuthirificatedAtMs() { - return lastAuthirificatedAtMs; - } - - public void setLastAuthirificatedAtMs(long lastAuthirificatedAtMs) { - this.lastAuthirificatedAtMs = lastAuthirificatedAtMs; - } - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 1 входа в существующую сессию (v2): - * SessionChallenge(sessionId) -> nonce - */ -public class Net_SessionChallenge_Request extends Net_Request { - - private String sessionId; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на SessionChallenge (v2). - * payload: { "nonce": "base64(32)" } - */ -public class Net_SessionChallenge_Response extends Net_Response { - - private String nonce; - - public String getNonce() { - return nonce; - } - - public void setNonce(String nonce) { - this.nonce = nonce; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 2 входа в существующую сессию (v2): - * SessionLogin(sessionId, timeMs, signatureB64) -> storagePwd, AUTH_STATUS_USER - * - * Подпись делается sessionKey (приватный ключ на устройстве) над строкой (UTF-8): - * SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} - * - * nonce берётся из SessionChallenge и хранится в ctx (одноразовый, TTL). - */ -public class Net_SessionLogin_Request extends Net_Request { - - private String sessionId; - private long timeMs; - private String signatureB64; - - /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ - private String clientInfo; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } - - public long getTimeMs() { - return timeMs; - } - - public void setTimeMs(long timeMs) { - this.timeMs = timeMs; - } - - public String getSignatureB64() { - return signatureB64; - } - - public void setSignatureB64(String signatureB64) { - this.signatureB64 = signatureB64; - } - - public String getClientInfo() { - return clientInfo; - } - - public void setClientInfo(String clientInfo) { - this.clientInfo = clientInfo; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на SessionLogin (v2). - * payload: { "storagePwd": "base64(32)" } - */ -public class Net_SessionLogin_Response extends Net_Response { - - private String storagePwd; - - public String getStoragePwd() { - return storagePwd; - } - - public void setStoragePwd(String storagePwd) { - this.storagePwd = storagePwd; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import server.logic.ws_protocol.Base64Ws; -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_AuthChallenge_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; - -import java.security.SecureRandom; - -/** - * AuthChallenge (v2) — шаг 1 создания новой сессии. - * - * Логика авторизации (v2): - * - Создание новой сессии возможно ТОЛЬКО через deviceKey пользователя. - * - Этот handler выдаёт одноразовый authNonce, который клиент использует во втором шаге: - * CreateAuthSession(..., signature(deviceKey, AUTH_CREATE_SESSION:...)) - * - * Что делает: - * 1) Проверяет login. - * 2) Находит пользователя (solana_users). - * 3) Пишет solanaUser в ctx, ставит AUTH_STATUS_AUTH_IN_PROGRESS. - * 4) Генерирует authNonce (base64url(32)) и сохраняет в ctx.authNonce. - */ -public class Net_AuthChallenge_Handler implements JsonMessageHandler { - - private static final SecureRandom RANDOM = new SecureRandom(); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - - Net_AuthChallenge_Request req = (Net_AuthChallenge_Request) baseReq; - - String login = req.getLogin(); - if (login == null || login.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_LOGIN", - "Пустой логин" - ); - } - - // Если по этому соединению уже есть залогиненный пользователь — не даём повторную авторификацию - if (ctx.getLogin() != null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "ALREADY_AUTHED", - "Попытка повторной авторификации для уже заданного login=" + ctx.getLogin() - ); - } - - SolanaUserEntry solanaUserEntry = SolanaUsersDAO.getInstance().getByLogin(login); - if (solanaUserEntry == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "UNKNOWN_USER", - "Пользователь с таким логином не найден" - ); - } - - ctx.setSolanaUser(solanaUserEntry); - ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS); - - byte[] buf = new byte[32]; - RANDOM.nextBytes(buf); - String authNonce = Base64Ws.encode(buf); - - ctx.setAuthNonce(authNonce); - - Net_AuthChallenge_Response resp = new Net_AuthChallenge_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setAuthNonce(authNonce); - - return resp; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -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_CloseActiveSession_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import server.ws.WsConnectionUtils; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; - -import java.sql.SQLException; - -/** - * CloseActiveSession (v2) — закрытие текущей или другой сессии. - * - * Логика авторизации (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Никаких подписей и AUTH_IN_PROGRESS здесь больше нет. - * - * Закрытие: - * - удаляем запись из БД - * - если по sessionId есть активный WS — закрываем его - */ -public class Net_CloseActiveSession_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_CloseActiveSession_Handler.class); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_CloseActiveSession_Request req = (Net_CloseActiveSession_Request) baseReq; - - if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "NOT_AUTHENTICATED", - "Операция доступна только для авторизованных пользователей" - ); - } - - SolanaUserEntry user = ctx.getSolanaUser(); - String currentLogin = user.getLogin(); - - String targetSessionId = req.getSessionId(); - if (targetSessionId == null || targetSessionId.isBlank()) { - if (ctx.getSessionId() != null && !ctx.getSessionId().isBlank()) { - targetSessionId = ctx.getSessionId(); - } else if (ctx.getActiveSession() != null && ctx.getActiveSession().getSessionId() != null) { - targetSessionId = ctx.getActiveSession().getSessionId(); - } else { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_SESSION_TO_CLOSE", - "Не удалось определить, какую сессию нужно закрыть" - ); - } - } - - ActiveSessionEntry targetSession; - try { - targetSession = ActiveSessionsDAO.getInstance().getBySessionId(targetSessionId); - } catch (SQLException e) { - log.error("Ошибка БД при поиске сессии для CloseActiveSession sessionId={}", targetSessionId, e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка доступа к базе данных при поиске сессии" - ); - } - - if (targetSession == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_NOT_FOUND", - "Сессия для закрытия не найдена" - ); - } - - if (currentLogin == null || !currentLogin.equals(targetSession.getLogin())) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_OF_ANOTHER_USER", - "Нельзя закрывать сессию другого пользователя" - ); - } - - boolean isCurrentSession = targetSessionId.equals(ctx.getSessionId()); - - closeActiveSession(targetSessionId, ctx, isCurrentSession); - - Net_CloseActiveSession_Response resp = new Net_CloseActiveSession_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - return resp; - } - - private void closeActiveSession(String targetSessionId, - ConnectionContext currentCtx, - boolean isCurrentSession) { - - try { - ActiveSessionsDAO.getInstance().deleteBySessionId(targetSessionId); - } catch (SQLException e) { - log.error("Ошибка БД при удалении сессии sessionId={}", targetSessionId, e); - } - - ConnectionContext ctxToClose = - ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId); - - if (ctxToClose == null) return; - - if (isCurrentSession && ctxToClose == currentCtx) { - new Thread(() -> { - try { Thread.sleep(50); } catch (InterruptedException ignored) {} - WsConnectionUtils.closeConnection( - ctxToClose, - 4000, - "Session closed by client via CloseActiveSession" - ); - }, "CloseSession-" + targetSessionId).start(); - } else { - WsConnectionUtils.closeConnection( - ctxToClose, - 4000, - "Session closed by client via CloseActiveSession" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.Base64Ws; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -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_CreateAuthSession_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import server.ws.WsConnectionUtils; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; -import shine.geo.ClientInfoService; -import shine.geo.GeoLookupService; -import utils.crypto.Ed25519Util; - -import org.eclipse.jetty.websocket.api.Session; - -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.sql.SQLException; - -/** - * CreateAuthSession (v2) — шаг 2 создания новой сессии (ТОЛЬКО deviceKey). - * - * Логика авторизации (v2): - * - Создание сессии: AuthChallenge(login) -> authNonce -> CreateAuthSession(...) - * - Клиент генерирует sessionKey (Ed25519), хранит приватный ключ у себя, - * отправляет на сервер ТОЛЬКО sessionPubKeyB64. - * - Сервер сохраняет sessionPubKeyB64 в active_sessions.session_key. - * - * Подпись deviceKey (Ed25519) проверяется над строкой (UTF-8): - * AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce} - * - * На выходе: - * - создаётся запись active_sessions - * - ctx становится AUTH_STATUS_USER (вход выполнен как "текущая сессия") - * - ответ: sessionId - */ -public class Net_CreateAuthSession__Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class); - private static final SecureRandom RANDOM = new SecureRandom(); - - public static final long ALLOWED_SKEW_MS = 30_000L; - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - - Net_CreateAuthSession_Request req = (Net_CreateAuthSession_Request) baseReq; - - if (ctx == null - || ctx.getSolanaUser() == null - || ctx.getAuthNonce() == null - || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS) { - - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_STEP1_CONTEXT", - "Шаг 1 авторизации не был корректно выполнен для данного соединения" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no step1 context or bad auth state"); - return err; - } - - SolanaUserEntry user = ctx.getSolanaUser(); - String login = user.getLogin(); - if (login == null || login.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "NO_LOGIN", - "Для пользователя не задан login в БД" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no login"); - return err; - } - - String storagePwd = req.getStoragePwd(); - if (storagePwd == null || storagePwd.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_STORAGE_PWD", - "Пустой storagePwd" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty storagePwd"); - return err; - } - - String sessionPubKeyB64 = req.getSessionPubKeyB64(); - if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SESSION_PUBKEY", - "Пустой sessionPubKeyB64" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty session pubkey"); - return err; - } - - // Проверим, что sessionPubKeyB64 декодируется в 32 байта - byte[] sessionPubKey32; - try { - sessionPubKey32 = Base64Ws.decode(sessionPubKeyB64); - } catch (IllegalArgumentException e) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "Некорректный base64 в sessionPubKeyB64" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session pubkey base64"); - return err; - } - if (sessionPubKey32.length != 32) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_SESSION_PUBKEY_LEN", - "sessionPubKey должен быть 32 байта" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session pubkey length"); - return err; - } - - String signatureB64 = req.getSignatureB64(); - if (signatureB64 == null || signatureB64.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SIGNATURE", - "Пустая цифровая подпись" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty signature"); - return err; - } - - long timeMs = req.getTimeMs(); - long nowMs = System.currentTimeMillis(); - long diff = Math.abs(nowMs - timeMs); - if (diff > ALLOWED_SKEW_MS) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "TIME_SKEW", - "Время клиента отличается от сервера более чем на 30 секунд" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: time skew"); - return err; - } - - String clientInfoFromClient = req.getClientInfo(); - if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) { - clientInfoFromClient = clientInfoFromClient.substring(0, 50); - } - - String devicePubKeyB64 = user.getDeviceKey(); - if (devicePubKeyB64 == null || devicePubKeyB64.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_DEVICE_KEY", - "Отсутствует deviceKey у пользователя" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no deviceKey"); - return err; - } - - String authNonce = ctx.getAuthNonce(); - - boolean sigOk; - try { - sigOk = verifyCreateSessionSignature( - user, - login, - authNonce, - timeMs, - signatureB64 - ); - } catch (IllegalArgumentException ex) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "Некорректный формат Base64 для ключа или подписи" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad base64"); - return err; - } - - if (!sigOk) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "BAD_SIGNATURE", - "Подпись не прошла проверку" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad signature"); - return err; - } - - // --- генерируем sessionId --- - String sessionId = generateRandom32B64Url(); - long now = System.currentTimeMillis(); - - // --- Сбор данных о клиенте (IP, UA, язык) --- - Session wsSession = ctx.getWsSession(); - String clientInfoFromRequest = ClientInfoService.buildClientInfoString(wsSession); - String userLanguage = ClientInfoService.extractPreferredLanguageTag(wsSession); - - String clientIp = ""; - if (wsSession != null) { - String ip = ClientInfoService.extractClientIp(wsSession); - if (ip != null) clientIp = ip; - - if (!clientIp.isBlank()) { - try { - GeoLookupService.resolveCountryCityOrIpWithCache(clientIp); - } catch (Exception e) { - log.debug("Geo lookup failed for ip={}", clientIp, e); - } - } - } - - // --- создаём запись ActiveSession и сохраняем в БД --- - ActiveSessionsDAO dao = ActiveSessionsDAO.getInstance(); - ActiveSessionEntry activeSessionEntry; - - try { - activeSessionEntry = new ActiveSessionEntry( - sessionId, - login, - sessionPubKeyB64, // session_key (pubkey) - storagePwd, - now, - now, - null, // pushEndpoint - null, // pushP256dhKey - null, // pushAuthKey - clientIp, - clientInfoFromClient, - clientInfoFromRequest, - userLanguage - ); - - dao.insert(activeSessionEntry); - } catch (SQLException e) { - log.error("Ошибка БД при создании новой сессии для login={}", login, e); - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR_SESSION_CREATE", - "Ошибка БД при создании сессии" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: db error"); - return err; - } - - // --- обновляем контекст --- - ctx.setActiveSession(activeSessionEntry); - ctx.setSessionId(sessionId); - ctx.setAuthNonce(null); - ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); - - ActiveConnectionsRegistry.getInstance().register(ctx); - - // --- формируем ответ --- - Net_CreateAuthSession_Response resp = new Net_CreateAuthSession_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setSessionId(sessionId); - return resp; - } - - private static boolean verifyCreateSessionSignature( - SolanaUserEntry user, - String login, - String authNonce, - long timeMs, - String signatureB64 - ) throws IllegalArgumentException { - - // deviceKey (pub, 32) - byte[] publicKey32 = Ed25519Util.keyFromBase64(user.getDeviceKey()); - byte[] signature64 = Base64Ws.decode(signatureB64); - - String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce; - byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); - - return Ed25519Util.verify(preimage, signature64, publicKey32); - } - - private static String generateRandom32B64Url() { - byte[] buf = new byte[32]; - RANDOM.nextBytes(buf); - return Base64Ws.encode(buf); - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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_ListSessions_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Response; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Response.SessionInfo; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; -import shine.geo.GeoLookupService; - -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.List; - -/** - * ListSessions (v2) — список активных сессий. - * - * Логика авторизации (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Никаких подписей здесь больше нет. - */ -public class Net_ListSessions_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_ListSessions_Handler.class); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_ListSessions_Request req = (Net_ListSessions_Request) baseReq; - - if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "NOT_AUTHENTICATED", - "Операция доступна только для авторизованных пользователей" - ); - } - - SolanaUserEntry user = ctx.getSolanaUser(); - String currentLogin = user.getLogin(); - - List sessions; - try { - sessions = ActiveSessionsDAO.getInstance().getByLogin(currentLogin); - } catch (SQLException e) { - log.error("Ошибка БД при получении списка сессий для login={}", currentLogin, e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR_LIST_SESSIONS", - "Ошибка доступа к базе данных при получении списка сессий" - ); - } - - List resultList = new ArrayList<>(); - for (ActiveSessionEntry s : sessions) { - SessionInfo info = new SessionInfo(); - info.setSessionId(s.getSessionId()); - info.setClientInfoFromClient(s.getClientInfoFromClient()); - info.setClientInfoFromRequest(s.getClientInfoFromRequest()); - info.setLastAuthirificatedAtMs(s.getLastAuthirificatedAtMs()); - - String ip = s.getClientIp(); - String geo = GeoLookupService.resolveCountryCityOrIpWithCache(ip); - info.setGeo(geo); - - resultList.add(info); - } - - Net_ListSessions_Response resp = new Net_ListSessions_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setSessions(resultList); - - return resp; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import server.logic.ws_protocol.Base64Ws; -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_SessionChallenge_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; - -import java.security.SecureRandom; -import java.sql.SQLException; - -/** - * SessionChallenge (v2) — шаг 1 входа в существующую сессию. - * - * Логика авторизации (v2): - * - Вход в существующую сессию ВСЕГДА в 2 шага: - * 1) SessionChallenge(sessionId) -> nonce - * 2) SessionLogin(sessionId, timeMs, signature(sessionKey, SESSION_LOGIN:...)) - * - * Что делает: - * - Проверяет, что sessionId существует в БД. - * - Генерирует одноразовый nonce (base64url(32)), сохраняет его в ctx: - * ctx.sessionLoginNonce, ctx.sessionLoginSessionId, ctx.sessionLoginNonceExpiresAtMs. - */ -public class Net_SessionChallenge_Handler implements JsonMessageHandler { - - private static final SecureRandom RANDOM = new SecureRandom(); - private static final long NONCE_TTL_MS = 60_000L; - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_SessionChallenge_Request req = (Net_SessionChallenge_Request) baseReq; - - String sessionId = req.getSessionId(); - if (sessionId == null || sessionId.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SESSION_ID", - "Пустой sessionId" - ); - } - - ActiveSessionEntry session; - try { - session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId); - } catch (SQLException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка доступа к базе данных" - ); - } - - if (session == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_NOT_FOUND", - "Сессия не найдена" - ); - } - - byte[] buf = new byte[32]; - RANDOM.nextBytes(buf); - String nonce = Base64Ws.encode(buf); - - long now = System.currentTimeMillis(); - ctx.setSessionLoginNonce(nonce); - ctx.setSessionLoginSessionId(sessionId); - ctx.setSessionLoginNonceExpiresAtMs(now + NONCE_TTL_MS); - - Net_SessionChallenge_Response resp = new Net_SessionChallenge_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setNonce(nonce); - return resp; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.Base64Ws; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -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_SessionLogin_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; -import shine.geo.ClientInfoService; -import shine.geo.GeoLookupService; -import utils.crypto.Ed25519Util; - -import java.nio.charset.StandardCharsets; -import java.sql.SQLException; - -/** - * SessionLogin (v2) — шаг 2 входа в существующую сессию (по sessionKey). - * - * Логика авторизации (v2): - * - SessionChallenge(sessionId) выдаёт nonce (одноразовый, TTL). - * - SessionLogin проверяет подпись sessionKey над строкой: - * SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} - * - sessionPubKey берём из БД: active_sessions.session_key (base64 32 bytes). - * - * При успехе: - * - ctx становится AUTH_STATUS_USER - * - обновляем метаданные сессии (lastAuth + clientIp + clientInfo + lang) - * - возвращаем storagePwd - */ -public class Net_SessionLogin_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_SessionLogin_Handler.class); - - private static final long ALLOWED_SKEW_MS = 30_000L; - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_SessionLogin_Request req = (Net_SessionLogin_Request) baseReq; - - String sessionId = req.getSessionId(); - if (sessionId == null || sessionId.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SESSION_ID", - "Пустой sessionId" - ); - } - - // проверка челленджа - if (ctx.getSessionLoginNonce() == null - || ctx.getSessionLoginSessionId() == null - || System.currentTimeMillis() > ctx.getSessionLoginNonceExpiresAtMs()) { - - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_CHALLENGE", - "Нет активного SessionChallenge или nonce истёк" - ); - } - - if (!sessionId.equals(ctx.getSessionLoginSessionId())) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "SESSION_ID_MISMATCH", - "nonce был выдан для другого sessionId" - ); - } - - long timeMs = req.getTimeMs(); - long nowMs = System.currentTimeMillis(); - if (Math.abs(nowMs - timeMs) > ALLOWED_SKEW_MS) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "TIME_SKEW", - "Время клиента отличается от сервера более чем на 30 секунд" - ); - } - - String signatureB64 = req.getSignatureB64(); - if (signatureB64 == null || signatureB64.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SIGNATURE", - "Пустая подпись" - ); - } - - ActiveSessionEntry session; - try { - session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId); - } catch (SQLException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка доступа к базе данных" - ); - } - - if (session == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_NOT_FOUND", - "Сессия не найдена" - ); - } - - String sessionPubKeyB64 = session.getSessionKey(); // это pubKey (Base64(32)) - if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "NO_SESSION_KEY", - "В сессии не задан session_key" - ); - } - - String nonce = ctx.getSessionLoginNonce(); - - boolean sigOk; - try { - sigOk = verifySessionLoginSignature(sessionPubKeyB64, sessionId, timeMs, nonce, signatureB64); - } catch (IllegalArgumentException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "Некорректный Base64 для ключа/подписи" - ); - } - - if (!sigOk) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "BAD_SIGNATURE", - "Подпись не прошла проверку" - ); - } - - // сжигаем nonce - ctx.setSessionLoginNonce(null); - ctx.setSessionLoginSessionId(null); - ctx.setSessionLoginNonceExpiresAtMs(0); - - // подтягиваем пользователя - SolanaUserEntry user; - try { - user = SolanaUsersDAO.getInstance().getByLogin(session.getLogin()); - } catch (SQLException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR_USER_LOOKUP", - "Ошибка доступа к базе данных при получении пользователя" - ); - } - - if (user == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "USER_NOT_FOUND_FOR_SESSION", - "Пользователь для данной сессии не найден" - ); - } - - // обновление метаданных - String clientInfoFromClient = req.getClientInfo(); - if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) { - clientInfoFromClient = clientInfoFromClient.substring(0, 50); - } - - String clientIp = null; - String clientInfoFromRequest = null; - String userLanguage = null; - - if (ctx.getWsSession() != null) { - clientIp = ClientInfoService.extractClientIp(ctx.getWsSession()); - clientInfoFromRequest = ClientInfoService.buildClientInfoString(ctx.getWsSession()); - userLanguage = ClientInfoService.extractPreferredLanguageTag(ctx.getWsSession()); - - if (clientIp != null && !clientIp.isBlank()) { - try { - GeoLookupService.resolveCountryCityOrIpWithCache(clientIp); - } catch (Exception e) { - log.debug("Geo lookup failed for ip={}", clientIp, e); - } - } - } - - long now = System.currentTimeMillis(); - try { - ActiveSessionsDAO.getInstance().updateOnRefresh( - sessionId, - now, - clientIp, - clientInfoFromClient, - clientInfoFromRequest, - userLanguage - ); - } catch (SQLException e) { - log.error("Ошибка БД при updateOnRefresh sessionId={}", sessionId, e); - } - - session.setLastAuthirificatedAtMs(now); - session.setClientIp(clientIp); - session.setClientInfoFromClient(clientInfoFromClient); - session.setClientInfoFromRequest(clientInfoFromRequest); - session.setUserLanguage(userLanguage); - - // ctx - ctx.setActiveSession(session); - ctx.setSolanaUser(user); - ctx.setSessionId(sessionId); - ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); - - ActiveConnectionsRegistry.getInstance().register(ctx); - - // ответ - Net_SessionLogin_Response resp = new Net_SessionLogin_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setStoragePwd(session.getStoragePwd()); - return resp; - } - - private static boolean verifySessionLoginSignature( - String sessionPubKeyB64, - String sessionId, - long timeMs, - String nonce, - String signatureB64 - ) throws IllegalArgumentException { - - // pubKey: Base64(32). (Ed25519Util.keyFromBase64 должен использовать стандартный Base64) - byte[] publicKey32 = Ed25519Util.keyFromBase64(sessionPubKeyB64); - - // signature: Base64(64) через единую утилиту WS-протокола - byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); - - String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce; - byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); - - return Ed25519Util.verify(preimage, signature64, publicKey32); - } -} -package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -public final class Net_AddBlock_Request extends Net_Request { - - private String blockchainName; // обязателен - private int blockNumber; // обязателен - private String prevBlockHash; // HEX(64) или "" для нулевого - private String blockBytesB64; // байты FULL-блока (raw+sig+hash) в Base64 - - public String getBlockchainName() { return blockchainName; } - public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } - - public int getBlockNumber() { return blockNumber; } - public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; } - - public String getPrevBlockHash() { return prevBlockHash; } - public void setPrevBlockHash(String prevBlockHash) { this.prevBlockHash = prevBlockHash; } - - public String getBlockBytesB64() { return blockBytesB64; } - public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; } -} -package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ: - * - reasonCode (null если ok) - * - serverLastGlobalNumber / serverLastGlobalHash - */ -public final class Net_AddBlock_Response extends Net_Response { - - /** null если ok, иначе строка причины (bad_block_base64, user_not_found, и т.п.) */ - private String reasonCode; - - /** что сервер считает последним по глобальной цепочке */ - private int serverLastGlobalNumber; - private String serverLastGlobalHash; - - public String getReasonCode() { return reasonCode; } - public void setReasonCode(String reasonCode) { this.reasonCode = reasonCode; } - - public int getServerLastGlobalNumber() { return serverLastGlobalNumber; } - public void setServerLastGlobalNumber(int v) { this.serverLastGlobalNumber = v; } - - public String getServerLastGlobalHash() { return serverLastGlobalHash; } - public void setServerLastGlobalHash(String v) { this.serverLastGlobalHash = v; } -} -package server.logic.ws_protocol.JSON.handlers.blockchain; - -import blockchain.BchBlockEntry; -import blockchain.BchCryptoVerifier; -import blockchain.MsgSubType; -import blockchain.body.BodyHasLine; -import blockchain.body.BodyHasTarget; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.Base64Ws; -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.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks; -import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter; -import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; -import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.BlocksDAO; -import shine.db.entities.BlockchainStateEntry; -import shine.db.entities.BlockEntry; -import utils.blockchain.BlockchainNameUtil; - -import java.util.Arrays; -import java.util.concurrent.locks.ReentrantLock; - -/** - * Net_AddBlock_Handler — единый хэндлер добавления блока (JSON). - * - * Новый порядок валидации (ТЗ): - * 1) Достаём из blockchain_state: last_block_number, last_block_hash - * 2) Проверяем: - * - incoming.blockNumber == last+1 - * - incoming.prevHash32 == last_hash (для genesis last_hash = 32 нулей) - * 3) Проверяем подпись Ed25519.verify(hash32(preimage), signature64, pubKey) - * 4) Если тип имеет линию: - * - если prevLineNumber != null: - * достаём hash блока prevLineNumber из blocks - * сравниваем с prevLineHash32 из body - * 5) Сохраняем блок в blocks + обновляем blockchain_state - * - * Важно: - * - Сетевой протокол AddBlock пока оставляем старые поля (globalNumber/prevGlobalHash), - * но внутренняя логика использует НОВЫЙ формат блока. - */ -public final class Net_AddBlock_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_AddBlock_Handler.class); - - private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); - private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); - - private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) { - - Net_AddBlock_Request req = (Net_AddBlock_Request) baseReq; - - String blockchainName = req.getBlockchainName(); - ReentrantLock lock = BlockchainLocks.lockFor(blockchainName); - lock.lock(); - try { - AddBlockResult r = addBlock( - blockchainName, - req.getBlockNumber(), // старое поле, пока оставляем - req.getPrevBlockHash(), // старое поле, пока оставляем - req.getBlockBytesB64() - ); - - Net_AddBlock_Response resp = new Net_AddBlock_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - - if (r.isOk()) { - resp.setStatus(WireCodes.Status.OK); - resp.setReasonCode(null); - } else { - resp.setStatus(r.httpStatus); - resp.setReasonCode(r.reasonCode); - } - - resp.setServerLastGlobalNumber(r.serverLastBlockNumber); - resp.setServerLastGlobalHash(r.serverLastBlockHashHex); - - return resp; - - } finally { - lock.unlock(); - } - } - - private AddBlockResult addBlock( - String blockchainName, - int globalNumberFromReq, - String prevGlobalHashHexFromReq, - String blockBytesB64 - ) { - if (blockchainName == null || blockchainName.isBlank()) { - log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "empty_blockchain_name", 0, ""); - } - - String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName); - if (login == null || login.isBlank()) { - log.warn("AddBlock: плохой blockchainName='{}' => login не получился (reqGlobalNumber={})", - blockchainName, globalNumberFromReq); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_name", 0, ""); - } - - // 1) state обязателен - final BlockchainStateEntry st; - try { - st = stateDAO.getByBlockchainName(blockchainName); - } catch (Exception e) { - log.error("AddBlock: ошибка БД при чтении blockchain_state (login={}, blockchainName={}, reqGlobalNumber={})", - login, blockchainName, globalNumberFromReq, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "db_error", 0, ""); - } - - if (st == null) { - log.warn("AddBlock: blockchain_state_not_found (login={}, blockchainName={}, reqGlobalNumber={})", - login, blockchainName, globalNumberFromReq); - return new AddBlockResult(WireCodes.Status.NOT_FOUND, "blockchain_state_not_found", -1, ""); - } - - final int serverLastNum = st.getLastBlockNumber(); - final byte[] serverLastHash32 = (serverLastNum < 0) - ? new byte[32] - : require32OrThrow(st.getLastBlockHash(), "state.last_block_hash is null/invalid"); - - final String serverLastHashHex = toHex(serverLastHash32); - - // 2) decode block - final byte[] blockBytes; - try { - blockBytes = decodeBase64(blockBytesB64); - } catch (Exception e) { - log.warn("AddBlock: некорректный base64 блока (login={}, blockchainName={}, reqGlobalNumber={})", - login, blockchainName, globalNumberFromReq, e); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_base64", serverLastNum, serverLastHashHex); - } - - // 3) лимит (оставляем как было) - try { - long oldSize = st.getFileSizeBytes(); - long limit = st.getSizeLimit(); - long newSize = safeAdd(oldSize, blockBytes.length); - - if (limit > 0 && newSize > limit) { - log.warn("AddBlock: limit_exceeded (login={}, blockchainName={}, oldSize={}, addLen={}, newSize={}, limit={})", - login, blockchainName, oldSize, blockBytes.length, newSize, limit); - return new AddBlockResult(413, "limit_exceeded", serverLastNum, serverLastHashHex); - } - } catch (Exception e) { - log.error("AddBlock: limit_check_failed (login={}, blockchainName={})", login, blockchainName, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "limit_check_failed", serverLastNum, serverLastHashHex); - } - - // 4) parse block - final BchBlockEntry block; - try { - block = new BchBlockEntry(blockBytes); - } catch (Exception e) { - log.warn("AddBlock: не удалось распарсить BchBlockEntry (login={}, blockchainName={}, bytesLen={})", - login, blockchainName, blockBytes.length, e); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_format", serverLastNum, serverLastHashHex); - } - - // body.check() - try { - block.body.check(); - } catch (Exception e) { - log.warn("AddBlock: body.check() не прошёл (login={}, blockchainName={}, blockNumber={}, type={}, ver={})", - login, blockchainName, block.blockNumber, (block.type & 0xFFFF), (block.version & 0xFFFF), e); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex); - } - - // 4.2) запрет дырок: blockNumber строго last+1 - int expectedBlockNumber = serverLastNum + 1; - if (block.blockNumber != expectedBlockNumber) { - log.warn("AddBlock: bad_block_number (login={}, blockchainName={}, пришёл={}, ожидали={}, serverLastNum={})", - login, blockchainName, block.blockNumber, expectedBlockNumber, serverLastNum); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_number", serverLastNum, serverLastHashHex); - } - - // (временная совместимость) req.globalNumber должен совпасть с block.blockNumber - if (globalNumberFromReq != block.blockNumber) { - log.warn("AddBlock: req_global_mismatch (login={}, blockchainName={}, reqGlobal={}, blockNumber={})", - login, blockchainName, globalNumberFromReq, block.blockNumber); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "req_global_mismatch", serverLastNum, serverLastHashHex); - } - - // 4.3) проверка цепочки по prevHash32 - if (!Arrays.equals(block.prevHash32, serverLastHash32)) { - log.warn("AddBlock: bad_prev_hash (login={}, blockchainName={}, blockNumber={}, clientPrev={}, serverPrev={})", - login, blockchainName, block.blockNumber, toHex(block.prevHash32), serverLastHashHex); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_prev_hash", serverLastNum, serverLastHashHex); - } - - // 5) pubKey - final byte[] pubKey32 = st.getBlockchainKeyBytes(); - if (pubKey32 == null || pubKey32.length != 32) { - log.warn("AddBlock: bad_blockchain_key_len (login={}, blockchainName={}, blockNumber={}, keyLen={})", - login, blockchainName, block.blockNumber, (pubKey32 == null ? -1 : pubKey32.length)); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex); - } - - // 6) подпись по hash32(preimage) - boolean sigOk; - try { - sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32); - } catch (Exception e) { - log.warn("AddBlock: signature_verify_failed (login={}, blockchainName={}, blockNumber={})", - login, blockchainName, block.blockNumber, e); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_signature", serverLastNum, serverLastHashHex); - } - - if (!sigOk) { - log.warn("AddBlock: bad_signature (login={}, blockchainName={}, blockNumber={})", - login, blockchainName, block.blockNumber); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_signature", serverLastNum, serverLastHashHex); - } - - // 7) line columns (only for BodyHasLine) - Integer lineCode = null; - Integer prevLineNumber = null; - byte[] prevLineHash32 = null; - Integer thisLineNumber = null; - - if (block.body instanceof BodyHasLine bl) { - lineCode = bl.lineCode(); - prevLineNumber = bl.prevLineBlockGlobalNumber(); - prevLineHash32 = bl.prevLineBlockHash32(); - thisLineNumber = bl.lineSeq(); - - // Нормализация: -1 не пишем в БД (для совместимости со старым TextBody) - if (prevLineNumber != null && prevLineNumber == -1) { - prevLineNumber = null; - prevLineHash32 = null; - thisLineNumber = null; - } - - // Если prevLineNumber задан — проверяем его хэш - if (prevLineNumber != null) { - try { - byte[] dbPrevHash = blocksDAO.getHashByNumber(blockchainName, prevLineNumber); - if (dbPrevHash == null) { - log.warn("AddBlock: prev_line_block_not_found (login={}, blockchainName={}, blockNumber={}, prevLineNumber={})", - login, blockchainName, block.blockNumber, prevLineNumber); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "prev_line_block_not_found", serverLastNum, serverLastHashHex); - } - if (!Arrays.equals(dbPrevHash, require32OrThrow(prevLineHash32, "prevLineHash32 invalid"))) { - log.warn("AddBlock: bad_prev_line_hash (login={}, blockchainName={}, blockNumber={}, prevLineNumber={})", - login, blockchainName, block.blockNumber, prevLineNumber); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_prev_line_hash", serverLastNum, serverLastHashHex); - } - } catch (Exception e) { - log.error("AddBlock: db_error_prev_line_check (login={}, blockchainName={}, blockNumber={})", - login, blockchainName, block.blockNumber, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "db_error_prev_line_check", serverLastNum, serverLastHashHex); - } - } - } - - // 8) сформировать запись и записать (DB + state + файл) - try { - BlockEntry be = new BlockEntry(); - be.setLogin(login); - be.setBchName(blockchainName); - - be.setBlockNumber(block.blockNumber); - be.setMsgType(block.type & 0xFFFF); - be.setMsgSubType(block.subType & 0xFFFF); - - be.setBlockBytes(block.toBytes()); - be.setBlockHash(block.getHash32()); - be.setBlockSignature(block.getSignature64()); - - // line columns (optional) - be.setLineCode(lineCode); - be.setPrevLineNumber(prevLineNumber); - be.setPrevLineHash(prevLineHash32); - be.setThisLineNumber(thisLineNumber); - - // target columns (optional) - if (block.body instanceof BodyHasTarget t) { - be.setToLogin(t.toLogin()); - be.setToBchName(t.toBchName()); - be.setToBlockNumber(t.toBlockGlobalNumber()); - be.setToBlockHash(t.toBlockHashBytes()); - } - - // edit helper (optional): если TEXT_EDIT_* — это "редактирование блока цели" - int type = block.type & 0xFFFF; - int sub = block.subType & 0xFFFF; - - if (type == 1 - && (sub == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || sub == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) - && be.getToBlockNumber() != null) { - be.setEditedByBlockNumber(be.getToBlockNumber()); - } - - dbWriter.appendBlockAndState(blockchainName, block, st, be); - - } catch (Exception e) { - log.error("AddBlock: внутренняя ошибка при записи блока (login={}, blockchainName={}, blockNumber={})", - login, blockchainName, block.blockNumber, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex); - } - - String newHashHex = toHex(block.getHash32()); - - log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}", - login, blockchainName, block.blockNumber, newHashHex); - - return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex); - } - - /* ===================================================================== */ - /* ====================== Helpers ====================================== */ - /* ===================================================================== */ - - private static byte[] decodeBase64(String b64) { - if (b64 == null) throw new IllegalArgumentException("blockBytesB64 == null"); - return Base64Ws.decode(b64); - } - - private static long safeAdd(long a, long b) { - long r = a + b; - if (((a ^ r) & (b ^ r)) < 0) throw new ArithmeticException("long overflow"); - return r; - } - - private static byte[] require32OrThrow(byte[] b, String msg) { - if (b == null || b.length != 32) throw new IllegalArgumentException(msg); - return b; - } - - private static String toHex(byte[] bytes) { - if (bytes == null) return "null"; - char[] HEX = "0123456789abcdef".toCharArray(); - char[] out = new char[bytes.length * 2]; - for (int i = 0; i < bytes.length; i++) { - int v = bytes[i] & 0xFF; - out[i * 2] = HEX[v >>> 4]; - out[i * 2 + 1] = HEX[v & 0x0F]; - } - return new String(out); - } - - private static final class AddBlockResult { - final int httpStatus; - final String reasonCode; - final int serverLastBlockNumber; - final String serverLastBlockHashHex; - - AddBlockResult(int httpStatus, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) { - this.httpStatus = httpStatus; - this.reasonCode = reasonCode; - this.serverLastBlockNumber = serverLastBlockNumber; - this.serverLastBlockHashHex = serverLastBlockHashHex; - } - - boolean isOk() { return httpStatus == WireCodes.Status.OK; } - } -} -package server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils; - -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.ReentrantLock; - -public final class BlockchainLocks { - private static final ConcurrentHashMap MAP = new ConcurrentHashMap<>(); - - private BlockchainLocks() {} - - public static ReentrantLock lockFor(String blockchainName) { - return MAP.computeIfAbsent(blockchainName, id -> new ReentrantLock(true)); // fair=true - } -} -package server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils; - -import blockchain.BchBlockEntry; -import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.BlocksDAO; -import shine.db.entities.BlockchainStateEntry; -import shine.db.entities.BlockEntry; -import utils.files.FileStoreUtil; - -import java.sql.Connection; -import java.sql.SQLException; - -/** - * BlockchainWriter — запись блока в DB + обновление state + запись в файл. - * - * ВАЖНО: - * - Это минимальный рабочий вариант под новый формат. - * - Если у тебя уже есть "атомарность" сложнее (tmp_bch + commit/recovery) — можно усилить потом. - */ -public final class BlockchainWriter { - - private final BlocksDAO blocksDAO; - private final BlockchainStateDAO stateDAO; - private final FileStoreUtil fs = FileStoreUtil.getInstance(); - - public BlockchainWriter(BlocksDAO blocksDAO, BlockchainStateDAO stateDAO) { - this.blocksDAO = blocksDAO; - this.stateDAO = stateDAO; - } - - public void appendBlockAndState(String blockchainName, - BchBlockEntry block, - BlockchainStateEntry st, - BlockEntry be) throws SQLException { - - long nowMs = System.currentTimeMillis(); - - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) { - c.setAutoCommit(false); - try { - // 1) insert block - blocksDAO.insert(c, be); - - // 2) update state - st.setLastBlockNumber(block.blockNumber); - st.setLastBlockHash(block.getHash32()); - st.setFileSizeBytes(st.getFileSizeBytes() + block.toBytes().length); - st.setUpdatedAtMs(nowMs); - - stateDAO.upsert(c, st); - - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - if (e instanceof SQLException se) throw se; - throw new SQLException("appendBlockAndState failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } - - // 3) append to file (минимально: просто дописать) - // Если у тебя уже есть логика tmp_bch+atomicReplace — можно заменить тут. - String fileName = fs.buildBlockchainFileName(blockchainName); - fs.addDataToFile(fileName, block.toBytes()); - } -} -package server.logic.ws_protocol.JSON.handlers.connections.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос GetFriendsLists — получить два списка "друзей" по connections_state. - * - * { - * "op": "GetFriendsLists", - * "requestId": "req-100", - * "payload": { - * "login": "anya" - * } - * } - * - * Возвращает: - * - out_friends: кому login поставил FRIEND - * - in_friends: кто поставил FRIEND этому login - * - * ПРО ДОСТУП (на будущее): - * Сейчас (MVP) без ограничений. Позже можно ограничить видимость связей. - */ -public class Net_GetFriendsLists_Request extends Net_Request { - - private String login; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } -} -package server.logic.ws_protocol.JSON.handlers.connections.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.ArrayList; -import java.util.List; - -/** - * Ответ GetFriendsLists. - * - * { - * "op": "GetFriendsLists", - * "requestId": "req-100", - * "status": 200, - * "payload": { - * "login": "Anya", // канонический регистр из БД - * "out_friends": ["Bob", "Kate"], // кому login поставил FRIEND - * "in_friends": ["Alex", "Kate"] // кто поставил FRIEND login - * } - * } - */ -public class Net_GetFriendsLists_Response extends Net_Response { - - private String login; - - private List out_friends = new ArrayList<>(); - private List in_friends = new ArrayList<>(); - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public List getOut_friends() { return out_friends; } - public void setOut_friends(List out_friends) { this.out_friends = out_friends; } - - public List getIn_friends() { return in_friends; } - public void setIn_friends(List in_friends) { this.in_friends = in_friends; } -} -package server.logic.ws_protocol.JSON.handlers.connections; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.connections.entyties.Net_GetFriendsLists_Request; -import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.MsgSubType; -import shine.db.SqliteDbController; -import shine.db.dao.ConnectionsStateDAO; - -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.util.List; - -/** - * GetFriendsLists — получить 2 списка: - * - out_friends: кому login поставил FRIEND - * - in_friends: кто поставил FRIEND этому login - * - * ВАЖНО: - * - login в запросе может быть любым регистром - * - в ответе возвращаем канонический регистр (как в solana_users.login) - * - * ПРИМЕЧАНИЕ: - * Таблица пользователей тут названа "solana_users". Если у тебя иначе — поменяй SQL. - */ -public class Net_GetFriendsLists_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_GetFriendsLists_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_GetFriendsLists_Request req = (Net_GetFriendsLists_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login" - ); - } - - final String loginAnyCase = req.getLogin().trim(); - - try { - SqliteDbController db = SqliteDbController.getInstance(); - ConnectionsStateDAO dao = ConnectionsStateDAO.getInstance(); - - try (Connection c = db.getConnection()) { - - // 1) Канонизируем login через solana_users (NOCASE) - String canonicalLogin = findCanonicalLogin(c, loginAnyCase); - if (canonicalLogin == null) { - return NetExceptionResponseFactory.error( - req, - 404, - "USER_NOT_FOUND", - "Пользователь не найден" - ); - } - - int relType = (int) MsgSubType.CONNECTION_FRIEND; - - // 2) Два списка (логины канонические) - List outFriends = dao.listOutgoingByRelTypeCanonical(c, canonicalLogin, relType); - List inFriends = dao.listIncomingByRelTypeCanonical(c, canonicalLogin, relType); - - Net_GetFriendsLists_Response resp = new Net_GetFriendsLists_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - resp.setLogin(canonicalLogin); - resp.setOut_friends(outFriends); - resp.setIn_friends(inFriends); - - return resp; - } - - } catch (Exception e) { - log.error("❌ Internal error GetFriendsLists", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } - - private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception { - String sql = """ - SELECT login - FROM solana_users - WHERE login = ? COLLATE NOCASE - LIMIT 1 - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, loginAnyCase); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return rs.getString("login"); - } - } - } -} -package server.logic.ws_protocol.JSON.handlers; - -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; - -/** - * Общий интерфейс для всех JSON-хэндлеров. - */ -public interface JsonMessageHandler { - - /** - * Обработать запрос и вернуть ответ. - * - * @param request распарсенный запрос - * @param ctx контекст текущего WebSocket-соединения - */ - Net_Response handle(Net_Request request, ConnectionContext ctx) throws Exception; -} - -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос AddUser — временная/тестовая регистрация локального пользователя. - * - * Клиент отправляет: - * - * { - * "op": "AddUser", - * "requestId": "test-add-1", - * "payload": { - * "login": "anya", - * "blockchainName": "anya-001", - * "solanaKey": "base64-ed25519-public-key-login", - * "blockchainKey": "base64-ed25519-public-key-blockchain", - * "deviceKey": "base64-ed25519-public-key-device", - * "bchLimit": 1000000 - * } - * } - * - * Все поля лежат внутри payload. - */ -public class Net_AddUser_Request extends Net_Request { - - private String login; - private String blockchainName; - - /** Ключ пользователя Solana (публичный ключ логина) */ - private String solanaKey; - - /** Ключ блокчейна (публичный ключ блокчейна) */ - private String blockchainKey; - - /** Ключ устройства (публичный ключ устройства) */ - private String deviceKey; - - private Integer bchLimit; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getBlockchainName() { return blockchainName; } - public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } - - public String getSolanaKey() { return solanaKey; } - public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; } - - public String getBlockchainKey() { return blockchainKey; } - public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } - - public String getDeviceKey() { return deviceKey; } - public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } - - public Integer getBchLimit() { return bchLimit; } - public void setBchLimit(Integer bchLimit) { this.bchLimit = bchLimit; } -} -// file: server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Response.java -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Успешный ответ на AddUser. - * - * Сейчас дополнительных полей нет — достаточно status=200. - * - * Пример: - * { - * "op": "AddUser", - * "requestId": "test-add-1", - * "status": 200, - * "payload": { } - * } - */ -public class Net_AddUser_Response extends Net_Response { - // При необходимости сюда можно добавить, например, флаг created/updated и т.п. -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос GetUser — проверка/получение пользователя по login. - * - * Клиент отправляет: - * - * { - * "op": "GetUser", - * "requestId": "u-1", - * "payload": { - * "login": "AnYa" - * } - * } - * - * Поиск по login выполняется без учёта регистра. - * В ответе возвращаем login/blockchainName с тем регистром, как в БД. - */ -public class Net_GetUser_Request extends Net_Request { - - private String login; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ GetUser. - * - * Всегда status=200. - * - * Пример (нет пользователя): - * { - * "op": "GetUser", - * "requestId": "u-1", - * "status": 200, - * "payload": { "exists": false } - * } - * - * Пример (есть пользователь): - * { - * "op": "GetUser", - * "requestId": "u-1", - * "status": 200, - * "payload": { - * "exists": true, - * "login": "Anya", - * "blockchainName": "anya-001", - * "solanaKey": "...", - * "blockchainKey": "...", - * "deviceKey": "..." - * } - * } - */ -public class Net_GetUser_Response extends Net_Response { - - private Boolean exists; - - private String login; - private String blockchainName; - private String solanaKey; - private String blockchainKey; - private String deviceKey; - - public Boolean getExists() { return exists; } - public void setExists(Boolean exists) { this.exists = exists; } - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getBlockchainName() { return blockchainName; } - public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } - - public String getSolanaKey() { return solanaKey; } - public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; } - - public String getBlockchainKey() { return blockchainKey; } - public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } - - public String getDeviceKey() { return deviceKey; } - public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос SearchUsers — поиск логинов по префиксу. - * - * Клиент отправляет: - * { - * "op": "SearchUsers", - * "requestId": "su-1", - * "payload": { "prefix": "any" } - * } - * - * Поиск по prefix выполняется без учёта регистра. - * В ответе возвращаем логины с тем регистром, как в БД. - */ -public class Net_SearchUsers_Request extends Net_Request { - - private String prefix; - - public String getPrefix() { return prefix; } - public void setPrefix(String prefix) { this.prefix = prefix; } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.ArrayList; -import java.util.List; - -/** - * Ответ SearchUsers. - * - * Всегда status=200. - * - * Пример: - * { - * "op": "SearchUsers", - * "requestId": "su-1", - * "status": 200, - * "payload": { - * "logins": ["Anya", "andrew", "Angel"] - * } - * } - */ -public class Net_SearchUsers_Response extends Net_Response { - - private List logins = new ArrayList<>(); - - public List getLogins() { return logins; } - public void setLogins(List logins) { this.logins = logins; } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.Base64Ws; -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.tempToTest.entyties.Net_AddUser_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.BlockchainStateEntry; -import shine.db.entities.SolanaUserEntry; -import utils.blockchain.BlockchainNameUtil; - -import java.sql.Connection; -import java.sql.SQLException; - -public class Net_AddUser_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_AddUser_Handler.class); - - /** TEST ONLY */ - private static final int TEST_BCH_LIMIT = 1_000_000; - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_AddUser_Request req = (Net_AddUser_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank() - || req.getBlockchainName() == null || req.getBlockchainName().isBlank() - || req.getSolanaKey() == null || req.getSolanaKey().isBlank() - || req.getBlockchainKey() == null || req.getBlockchainKey().isBlank() - || req.getDeviceKey() == null || req.getDeviceKey().isBlank()) { - - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login/blockchainName/solanaKey/blockchainKey/deviceKey" - ); - } - - // blockchainName должен быть вида: -NNN - if (!BlockchainNameUtil.isBlockchainNameMatchesLogin(req.getBlockchainName(), req.getLogin())) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BLOCKCHAIN_NAME", - "blockchainName должен быть вида -NNN (пример: anya-001)" - ); - } - - int limit = (req.getBchLimit() == null || req.getBchLimit() <= 0) - ? TEST_BCH_LIMIT - : req.getBchLimit(); - - try { - // базовая валидация форматов ключей: Base64(32 bytes) - byte[] solanaKey32; - byte[] blockchainKey32; - byte[] deviceKey32; - - try { - solanaKey32 = Base64Ws.decodeLen(req.getSolanaKey(), 32, "solanaKey"); - blockchainKey32 = Base64Ws.decodeLen(req.getBlockchainKey(), 32, "blockchainKey"); - deviceKey32 = Base64Ws.decodeLen(req.getDeviceKey(), 32, "deviceKey"); - } catch (IllegalArgumentException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_KEY_FORMAT", - e.getMessage() - ); - } - - // (переменные не используются дальше, но оставляем для ясности проверки длины) - if (solanaKey32.length != 32 || blockchainKey32.length != 32 || deviceKey32.length != 32) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_KEY_FORMAT", - "solanaKey/blockchainKey/deviceKey должны быть Base64(32 bytes)" - ); - } - - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); - BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); - - SqliteDbController db = SqliteDbController.getInstance(); - - try (Connection c = db.getConnection()) { - c.setAutoCommit(false); - - // 1. Проверяем, что пользователя нет (case-insensitive) - if (usersDAO.getByLogin(c, req.getLogin()) != null) { - return NetExceptionResponseFactory.error( - req, - 409, - "USER_ALREADY_EXISTS", - "Пользователь с таким login уже существует" - ); - } - - // 2. Проверяем, что blockchainName ещё нет (case-sensitive, как в БД) - if (usersDAO.existsByBlockchainName(c, req.getBlockchainName())) { - return NetExceptionResponseFactory.error( - req, - 409, - "BLOCKCHAIN_ALREADY_EXISTS", - "Пользователь с таким blockchainName уже существует" - ); - } - - // 3. На всякий случай оставляем старую проверку blockchain_state, - // потому что эта таблица нужна серверу (состояние цепочки/лимиты). - if (stateDAO.getByBlockchainName(c, req.getBlockchainName()) != null) { - return NetExceptionResponseFactory.error( - req, - 409, - "BLOCKCHAIN_STATE_ALREADY_EXISTS", - "blockchain_state уже существует" - ); - } - - // 4. Создаём пользователя (все поля теперь лежат в solana_users) - SolanaUserEntry user = new SolanaUserEntry(); - user.setLogin(req.getLogin()); - user.setBlockchainName(req.getBlockchainName()); - user.setSolanaKey(req.getSolanaKey()); - user.setBlockchainKey(req.getBlockchainKey()); - user.setDeviceKey(req.getDeviceKey()); - - usersDAO.insert(c, user); - - // 5. Создаём INITIAL blockchain_state (для работы сервера) - BlockchainStateEntry st = new BlockchainStateEntry(); - st.setBlockchainName(req.getBlockchainName()); - st.setLogin(req.getLogin()); - st.setBlockchainKey(req.getBlockchainKey()); // Base64(32) - st.setLastBlockNumber(-1); - st.setLastBlockHash(new byte[32]); - st.setFileSizeBytes(0); - st.setSizeLimit(limit); - st.setUpdatedAtMs(System.currentTimeMillis()); - - stateDAO.upsert(c, st); - - c.commit(); - } - - Net_AddUser_Response resp = new Net_AddUser_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - log.info("✅ AddUser ok: login={}, blockchainName={}, limit={}", - req.getLogin(), req.getBlockchainName(), limit); - - return resp; - - } catch (SQLException e) { - log.error("❌ DB error AddUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error AddUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.tempToTest.entyties.Net_GetUser_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; - -import java.sql.SQLException; - -public class Net_GetUser_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_GetUser_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_GetUser_Request req = (Net_GetUser_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank()) { - // тут логичнее BAD_REQUEST, но ты просил: "нет пользователя" тоже 200. - // Поэтому BAD_REQUEST оставляем только на реально пустой login. - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login" - ); - } - - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); - - try { - SolanaUserEntry u = usersDAO.getByLogin(req.getLogin()); - - Net_GetUser_Response resp = new Net_GetUser_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - if (u == null) { - resp.setExists(false); - log.info("ℹ️ GetUser: not found for login={}", req.getLogin()); - return resp; - } - - // ВАЖНО: - // - Поиск по login был case-insensitive, - // - а тут возвращаем login/blockchainName как в БД (с исходным регистром). - resp.setExists(true); - resp.setLogin(u.getLogin()); - resp.setBlockchainName(u.getBlockchainName()); - resp.setSolanaKey(u.getSolanaKey()); - resp.setBlockchainKey(u.getBlockchainKey()); - resp.setDeviceKey(u.getDeviceKey()); - - log.info("✅ GetUser: found login={}, blockchainName={}", u.getLogin(), u.getBlockchainName()); - return resp; - - } catch (SQLException e) { - log.error("❌ DB error GetUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error GetUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.tempToTest.entyties.Net_SearchUsers_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_SearchUsers_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; - -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.List; - -public class Net_SearchUsers_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_SearchUsers_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_SearchUsers_Request req = (Net_SearchUsers_Request) baseRequest; - - if (req.getPrefix() == null || req.getPrefix().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: prefix" - ); - } - - String prefix = req.getPrefix().trim(); - - try { - SolanaUsersDAO dao = SolanaUsersDAO.getInstance(); - List users = dao.searchByLoginPrefix(prefix); // case-insensitive + LIMIT 5 - - List logins = new ArrayList<>(); - for (SolanaUserEntry u : users) { - if (u != null && u.getLogin() != null) { - logins.add(u.getLogin()); // регистр как в БД - } - } - - Net_SearchUsers_Response resp = new Net_SearchUsers_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setLogins(logins); - - log.info("✅ SearchUsers ok: prefix='{}' -> {}", prefix, logins.size()); - return resp; - - } catch (SQLException e) { - log.error("❌ DB error SearchUsers", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error SearchUsers", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос GetUserParam — получить один параметр пользователя. - * - * { - * "op": "GetUserParam", - * "requestId": "req-1", - * "payload": { - * "login": "anya", - * "param": "feed:lastSeenGlobal" - * } - * } - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) этот запрос не ограничивает просмотр параметров, т.к. проект в тестовом режиме. - * Позже, вероятно, потребуется ограничить: кто и какие параметры может читать (сессия/права). - * Но для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_GetUserParam_Request extends Net_Request { - - private String login; - private String param; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ GetUserParam. - * - * Если найден: - * { - * "op": "GetUserParam", - * "requestId": "req-1", - * "status": 200, - * "payload": { - * "login": "anya", - * "param": "feed:lastSeenGlobal", - * "time_ms": 1736000000123, - * "value": "105", - * "device_key": "base64-32", - * "signature": "base64-64" - * } - * } - * - * Если не найден: - * status=404, payload пустой. - */ -public class Net_GetUserParam_Response extends Net_Response { - - private String login; - private String param; - private Long time_ms; - private String value; - private String device_key; - private String signature; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } - - public Long getTime_ms() { return time_ms; } - public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } - - public String getValue() { return value; } - public void setValue(String value) { this.value = value; } - - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос ListUserParams — получить все сохранённые параметры пользователя. - * - * { - * "op": "ListUserParams", - * "requestId": "req-2", - * "payload": { - * "login": "anya" - * } - * } - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) запрос не ограничивает просмотр параметров. - * В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры. - * Для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_ListUserParams_Request extends Net_Request { - - private String login; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.ArrayList; -import java.util.List; - -/** - * Ответ ListUserParams — список всех параметров пользователя. - * - * { - * "op": "ListUserParams", - * "requestId": "req-2", - * "status": 200, - * "payload": { - * "login": "anya", - * "params": [ - * { - * "login": "anya", - * "param": "feed:lastSeenGlobal", - * "time_ms": 1736000000123, - * "value": "105", - * "device_key": "base64-32", - * "signature": "base64-64" - * }, - * ... - * ] - * } - * } - */ -public class Net_ListUserParams_Response extends Net_Response { - - private String login; - private List params = new ArrayList<>(); - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public List getParams() { return params; } - public void setParams(List params) { this.params = params; } - - public static class Item { - private String login; - private String param; - private Long time_ms; - private String value; - private String device_key; - private String signature; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } - - public Long getTime_ms() { return time_ms; } - public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } - - public String getValue() { return value; } - public void setValue(String value) { this.value = value; } - - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос UpsertUserParam — добавить/обновить сохранённый параметр пользователя. - * - * Клиент отправляет: - * - * { - * "op": "UpsertUserParam", - * "requestId": "req-123", - * "payload": { - * "login": "anya", - * "param": "feed:lastSeenGlobal", - * "time_ms": 1736000000123, - * "value": "105", - * "device_key": "base64-ed25519-public-key-32", - * "signature": "base64-ed25519-signature-64" - * } - * } - * - * Подпись считается от UTF-8 строки: - * USER_PARAMETER_PREFIX + login + param + time_ms + value - */ -public class Net_UpsertUserParam_Request extends Net_Request { - - private String login; - private String param; - private Long time_ms; - private String value; - - private String device_key; - private String signature; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } - - public Long getTime_ms() { return time_ms; } - public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } - - public String getValue() { return value; } - public void setValue(String value) { this.value = value; } - - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на UpsertUserParam. - * - * Успех: - * { - * "op": "UpsertUserParam", - * "requestId": "req-123", - * "status": 200, - * "payload": { } - * } - */ -public class Net_UpsertUserParam_Response extends Net_Response { - // MVP: без payload. При желании позже можно добавить created/updated. -} -package server.logic.ws_protocol.JSON.handlers.userParams; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.userParams.entyties.Net_GetUserParam_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.UserParamsDAO; -import shine.db.entities.UserParamEntry; - -import java.sql.Connection; - -/** - * GetUserParam — получить один параметр пользователя. - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) запрос не ограничивает просмотр параметров. - * В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры. - * Для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_GetUserParam_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_GetUserParam_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_GetUserParam_Request req = (Net_GetUserParam_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank() - || req.getParam() == null || req.getParam().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login/param" - ); - } - - String login = req.getLogin().trim(); - String param = req.getParam().trim(); - - try { - SqliteDbController db = SqliteDbController.getInstance(); - UserParamsDAO dao = UserParamsDAO.getInstance(); - - try (Connection c = db.getConnection()) { - UserParamEntry e = dao.getByLoginAndParam(c, login, param); - - if (e == null) { - Net_GetUserParam_Response resp = new Net_GetUserParam_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(404); - return resp; - } - - Net_GetUserParam_Response resp = new Net_GetUserParam_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - resp.setLogin(e.getLogin()); - resp.setParam(e.getParam()); - resp.setTime_ms(e.getTimeMs()); - resp.setValue(e.getValue()); - resp.setDevice_key(e.getDeviceKey()); - resp.setSignature(e.getSignature()); - - return resp; - } - - } catch (Exception e) { - log.error("❌ Internal error GetUserParam", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.userParams.entyties.Net_ListUserParams_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.UserParamsDAO; -import shine.db.entities.UserParamEntry; - -import java.sql.Connection; -import java.util.ArrayList; -import java.util.List; - -/** - * ListUserParams — получить все параметры пользователя. - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) запрос не ограничивает просмотр параметров. - * В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры. - * Для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_ListUserParams_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_ListUserParams_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_ListUserParams_Request req = (Net_ListUserParams_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login" - ); - } - - String login = req.getLogin().trim(); - - try { - SqliteDbController db = SqliteDbController.getInstance(); - UserParamsDAO dao = UserParamsDAO.getInstance(); - - List entries; - try (Connection c = db.getConnection()) { - entries = dao.getByLogin(c, login); - } - - Net_ListUserParams_Response resp = new Net_ListUserParams_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - resp.setLogin(login); - - List items = new ArrayList<>(); - for (UserParamEntry e : entries) { - Net_ListUserParams_Response.Item it = new Net_ListUserParams_Response.Item(); - it.setLogin(e.getLogin()); - it.setParam(e.getParam()); - it.setTime_ms(e.getTimeMs()); - it.setValue(e.getValue()); - it.setDevice_key(e.getDeviceKey()); - it.setSignature(e.getSignature()); - items.add(it); - } - resp.setParams(items); - - return resp; - - } catch (Exception e) { - log.error("❌ Internal error ListUserParams", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.Base64Ws; -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.userParams.entyties.Net_UpsertUserParam_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.SolanaUsersDAO; -import shine.db.dao.UserParamsDAO; -import shine.db.entities.SolanaUserEntry; -import shine.db.entities.UserParamEntry; -import utils.config.ShineSignatureConstants; -import utils.crypto.Ed25519Util; - -import java.nio.charset.StandardCharsets; -import java.sql.Connection; -import java.sql.SQLException; - -/** - * Net_UpsertUserParam_Handler - * - * Делает (MVP, без "сессий"): - * 1) Проверка входных полей. - * 2) Проверка подписи Ed25519 по device_key. - * 3) Проверка, что пользователь существует и что device_key принадлежит этому login. - * 4) Атомарная запись в БД "только если time_ms новее" (UPSERT + WHERE). - * - * ВАЖНО: - * - НИКАКИХ ручных транзакций / BEGIN здесь нет. - * - autoCommit=true, каждый statement завершённый сам по себе. - * - Гонки не страшны: если за время проверок кто-то записал более новый time_ms, - * наш финальный UPSERT просто вернёт 0 обновлённых строк. - */ -public class Net_UpsertUserParam_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_UpsertUserParam_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_UpsertUserParam_Request req = (Net_UpsertUserParam_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank() - || req.getParam() == null || req.getParam().isBlank() - || req.getTime_ms() == null || req.getTime_ms() <= 0 - || req.getValue() == null - || req.getDevice_key() == null || req.getDevice_key().isBlank() - || req.getSignature() == null || req.getSignature().isBlank()) { - - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login/param/time_ms/value/device_key/signature" - ); - } - - final String login = req.getLogin().trim(); - final String param = req.getParam().trim(); - final long timeMs = req.getTime_ms(); - final String value = req.getValue(); - final String deviceKeyB64 = req.getDevice_key().trim(); - final String signatureB64 = req.getSignature().trim(); - - try { - // ---------------- Base64 decode ---------------- - byte[] pubKey32; - byte[] sig64; - try { - pubKey32 = Base64Ws.decodeLen(deviceKeyB64, 32, "device_key"); - sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature"); - } catch (IllegalArgumentException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "device_key/signature должны быть Base64" - ); - } - - // ---------------- Signature verify ---------------- - String signText = ShineSignatureConstants.USER_PARAMETER_PREFIX - + login - + param - + timeMs - + value; - - byte[] signBytes = signText.getBytes(StandardCharsets.UTF_8); - - boolean sigOk = Ed25519Util.verify(signBytes, sig64, pubKey32); - if (!sigOk) { - return NetExceptionResponseFactory.error( - req, - 403, - "SIGNATURE_INVALID", - "Подпись не прошла проверку" - ); - } - - // ---------------- DB checks + upsert ---------------- - SqliteDbController db = SqliteDbController.getInstance(); - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); - UserParamsDAO paramsDAO = UserParamsDAO.getInstance(); - - try (Connection c = db.getConnection()) { - // 1) user exists - SolanaUserEntry user = usersDAO.getByLogin(c, login); - if (user == null) { - return NetExceptionResponseFactory.error( - req, - 404, - "USER_NOT_FOUND", - "Пользователь не найден" - ); - } - - // 2) device key must match the user's stored deviceKey - String userDeviceKey = user.getDeviceKey(); - if (userDeviceKey == null || userDeviceKey.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "USER_DEVICE_KEY_EMPTY", - "У пользователя не задан deviceKey в БД" - ); - } - - if (!userDeviceKey.trim().equals(deviceKeyB64)) { - return NetExceptionResponseFactory.error( - req, - 403, - "DEVICE_KEY_MISMATCH", - "device_key не соответствует пользователю" - ); - } - - // 3) atomic upsert-if-newer - UserParamEntry e = new UserParamEntry( - login, - param, - timeMs, - value, - deviceKeyB64, - signatureB64 - ); - - int changed = paramsDAO.upsertIfNewer(c, e); - - Net_UpsertUserParam_Response resp = new Net_UpsertUserParam_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - if (changed == 1) { - log.info("✅ UpsertUserParam applied: login={}, param={}, time_ms={}", login, param, timeMs); - } else { - // 0 строк — значит в БД уже есть time_ms >= incoming - log.info("ℹ️ UpsertUserParam ignored (not newer): login={}, param={}, time_ms={}", login, param, timeMs); - } - - return resp; - } - - } catch (SQLException e) { - log.error("❌ DB error UpsertUserParam", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error UpsertUserParam", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; -import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; - -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; - -// --- 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; - -// --- 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; - -// --- NEW v2 entities --- -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.blockchain.Net_AddBlock_Handler; -import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; - -import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request; - -import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request; - -// --- NEW: SearchUsers --- -import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_SearchUsers_Request; - -import server.logic.ws_protocol.JSON.handlers.userParams.Net_GetUserParam_Handler; -import server.logic.ws_protocol.JSON.handlers.userParams.Net_ListUserParams_Handler; -import server.logic.ws_protocol.JSON.handlers.userParams.Net_UpsertUserParam_Handler; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Request; - -// --- subscriptions --- - -// --- NEW: connections friends lists --- -import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler; -import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Request; - -import java.util.Map; - -/** - * JsonHandlerRegistry — единое место, где руками регистрируются - * JSON-операции: op → handler и op → requestClass. - */ -public final class JsonHandlerRegistry { - - private static final Map HANDLERS = Map.ofEntries( - Map.entry("AddUser", new Net_AddUser_Handler()), - Map.entry("GetUser", new Net_GetUser_Handler()), - Map.entry("SearchUsers", new Net_SearchUsers_Handler()), - - // --- auth --- - Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()), - Map.entry("CreateAuthSession", new Net_CreateAuthSession__Handler()), - Map.entry("CloseActiveSession", new Net_CloseActiveSession_Handler()), - Map.entry("ListSessions", new Net_ListSessions_Handler()), - - // --- login to existing session in 2 steps --- - Map.entry("SessionChallenge", new Net_SessionChallenge_Handler()), - Map.entry("SessionLogin", new Net_SessionLogin_Handler()), - - // --- blockchain --- - Map.entry("AddBlock", new Net_AddBlock_Handler()), - - // --- userParams --- - Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()), - Map.entry("GetUserParam", new Net_GetUserParam_Handler()), - Map.entry("ListUserParams", new Net_ListUserParams_Handler()), - - // --- connections --- - Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()) - - // --- subscriptions --- -// Map.entry("ListSubscribedChannels", new Net_GetSubscribedChannels_Handler()) - ); - - private static final Map> REQUEST_TYPES = Map.ofEntries( - Map.entry("AddUser", Net_AddUser_Request.class), - Map.entry("GetUser", Net_GetUser_Request.class), - Map.entry("SearchUsers", Net_SearchUsers_Request.class), - - // --- auth --- - Map.entry("AuthChallenge", Net_AuthChallenge_Request.class), - Map.entry("CreateAuthSession", Net_CreateAuthSession_Request.class), - Map.entry("CloseActiveSession", Net_CloseActiveSession_Request.class), - Map.entry("ListSessions", Net_ListSessions_Request.class), - - // --- NEW v2 --- - Map.entry("SessionChallenge", Net_SessionChallenge_Request.class), - Map.entry("SessionLogin", Net_SessionLogin_Request.class), - - // --- blockchain --- - Map.entry("AddBlock", Net_AddBlock_Request.class), - - // --- userParams --- - Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class), - Map.entry("GetUserParam", Net_GetUserParam_Request.class), - Map.entry("ListUserParams", Net_ListUserParams_Request.class), - - - // --- connections --- - Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class) - ); - - private JsonHandlerRegistry() { } - - public static Map getHandlers() { - return HANDLERS; - } - - public static Map> getRequestTypes() { - return REQUEST_TYPES; - } -} -package server.logic.ws_protocol.JSON; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.entyties.Net_Exception_Response; -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.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; - -import java.util.Map; - -/** - * JsonInboundProcessor — обработка JSON-сообщений. - * - * 1) Парсит общий пакет (op, requestId, payload). - * 2) По op выбирает класс запроса и хэндлер. - * 3) Собирает "плоский" объект: op + requestId + поля из payload. - * 4) Маппит его в NetRequest через ObjectMapper. - * 5) Вызывает хэндлер, получает NetResponse. - * 6) Собирает JSON-ответ: - * { - * "op": ..., - * "requestId": ..., - * "status": ..., - * "payload": { все поля response, кроме op/requestId/status/payload } - * } - */ -public final class JsonInboundProcessor { - - private static final Logger log = LoggerFactory.getLogger(JsonInboundProcessor.class); - - private static final ObjectMapper JSON_MAPPER = new ObjectMapper() - .setSerializationInclusion(JsonInclude.Include.NON_NULL); - - private static final Map JSON_HANDLERS = - JsonHandlerRegistry.getHandlers(); - - private static final Map> JSON_REQUEST_TYPES = - JsonHandlerRegistry.getRequestTypes(); - - private JsonInboundProcessor() { - // utility - } - - public static String processJson(String json, ConnectionContext ctx) { - String op = null; - String requestId = null; - - // Для лога полезно знать, кто прислал (хотя бы login/sessionId, если есть) - String ctxLogin = safe(ctx != null ? ctx.getLogin() : null); - String ctxSessionId = safe(ctx != null ? ctx.getSessionId() : null); - - try { - if (json == null || json.isBlank()) { - Net_Exception_Response err = NetExceptionResponseFactory.error( - null, - null, - WireCodes.Status.BAD_REQUEST, - "EMPTY_JSON", - "Пустое JSON-сообщение" - ); - - String out = writeResponse(err); - - // DEBUG: что пришло / что ушло - if (log.isDebugEnabled()) { - log.debug("JSON IN (login={}, sessionId={}): ", ctxLogin, ctxSessionId); - log.debug("JSON OUT (login={}, sessionId={}): {}", ctxLogin, ctxSessionId, shorten(out, 1200)); - } - return out; - } - - // DEBUG: сырой вход (обрезаем, чтобы не убить лог) - if (log.isDebugEnabled()) { - log.debug("JSON IN (login={}, sessionId={}): {}", ctxLogin, ctxSessionId, shorten(json, 1200)); - } - - // 1) Парсим общий пакет - JsonNode root = JSON_MAPPER.readTree(json); - - // 2) op и requestId из корня - op = getTextOrNull(root, "op"); - requestId = getTextOrNull(root, "requestId"); - - if (op == null || op.isEmpty()) { - Net_Exception_Response err = NetExceptionResponseFactory.error( - null, - requestId, - WireCodes.Status.BAD_REQUEST, - "NO_OP", - "Поле 'op' отсутствует или пустое" - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - - JsonMessageHandler handler = JSON_HANDLERS.get(op); - Class reqClass = JSON_REQUEST_TYPES.get(op); - - if (handler == null || reqClass == null) { - Net_Exception_Response err = NetExceptionResponseFactory.error( - op, - requestId, - WireCodes.Status.BAD_REQUEST, - "UNKNOWN_OP", - "Неизвестная операция: " + op - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - - // 3) Берём payload - JsonNode payloadNode = root.get("payload"); - if (payloadNode == null || payloadNode.isNull()) { - Net_Exception_Response err = NetExceptionResponseFactory.error( - op, - requestId, - WireCodes.Status.BAD_REQUEST, - "NO_PAYLOAD", - "Поле 'payload' отсутствует" - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - if (!payloadNode.isObject()) { - Net_Exception_Response err = NetExceptionResponseFactory.error( - op, - requestId, - WireCodes.Status.BAD_REQUEST, - "BAD_PAYLOAD", - "Поле 'payload' должно быть объектом" - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - - // 3.1 Собираем "плоский" объект для маппинга в NetRequest: - // op + requestId + поля из payload - ObjectNode merged = JSON_MAPPER.createObjectNode(); - - // Добавляем op и requestId, чтобы они попали в NetRequest - merged.put("op", op); - if (requestId != null) merged.put("requestId", requestId); - - // Добавляем все поля из payload внутрь - merged.setAll((ObjectNode) payloadNode); - - // 4) Маппим в конкретный класс NetRequest - Net_Request request; - try { - request = JSON_MAPPER.treeToValue(merged, reqClass); - } catch (Exception mapErr) { - // Важно: вот это часто “теряется”, если не логировать отдельно - log.error("❌ JSON map error (op={}, requestId={}, login={}, sessionId={}): merged={}", - op, safe(requestId), ctxLogin, ctxSessionId, shorten(merged.toString(), 1200), mapErr); - - Net_Exception_Response err = NetExceptionResponseFactory.error( - op, - requestId, - WireCodes.Status.BAD_REQUEST, - "BAD_REQUEST_FORMAT", - "Некорректный формат запроса: не удалось распарсить поля payload" - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - - // DEBUG: нормализованный запрос (уже распарсен) - if (log.isDebugEnabled()) { - log.debug("REQ OBJ (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(safeToString(request), 1200)); - } - - // 5) Вызываем хэндлер - Net_Response response; - try { - response = handler.handle(request, ctx); - } catch (Exception handlerError) { - // ✅ Вот тут как раз и должны “появляться ошибки в логере” - log.error("💥 Handler error (op={}, requestId={}, login={}, sessionId={})", - op, safe(requestId), ctxLogin, ctxSessionId, handlerError); - - Net_Exception_Response err = NetExceptionResponseFactory.error( - op, - requestId, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_HANDLER_ERROR", - "Неожиданная ошибка при обработке операции: " + op - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - - // На всякий случай: если хэндлер не выставил op/requestId - if (response.getOp() == null) response.setOp(op); - if (response.getRequestId() == null) response.setRequestId(requestId); - - // 6) Универсальная сборка ответа - String out = writeResponse(response); - - // DEBUG: ответ ушёл - if (log.isDebugEnabled()) { - log.debug("RESP OBJ (login={}, sessionId={}, op={}, requestId={}, status={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), response.getStatus(), shorten(safeToString(response), 1200)); - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}, status={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), response.getStatus(), shorten(out, 1200)); - } - - return out; - - } catch (Exception e) { - // ✅ Любая неожиданная ошибка парсинга/обработки — в лог - log.error("❌ JSON processing error (op={}, requestId={}, login={}, sessionId={})", - safe(op), safe(requestId), safe(ctxLogin), safe(ctxSessionId), e); - - Net_Exception_Response err = NetExceptionResponseFactory.error( - op != null ? op : "Unknown", - requestId, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - - String out = writeResponse(err); - - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - - return out; - } - } - - // --- helpers --- - - private static String getTextOrNull(JsonNode node, String field) { - if (node == null || !node.has(field) || node.get(field).isNull()) return null; - return node.get(field).asText(); - } - - /** - * Унифицированная сериализация любого NetResponse в формат: - * { - * "op": ..., - * "requestId": ..., - * "status": ..., - * "payload": { ... } - * } - */ - private static String writeResponse(Net_Response response) { - try { - // Конвертируем полный объект ответа в ObjectNode - ObjectNode full = JSON_MAPPER.convertValue(response, ObjectNode.class); - - // То, что должно остаться наверху: - String op = full.hasNonNull("op") ? full.get("op").asText() : null; - String requestId = full.hasNonNull("requestId") ? full.get("requestId").asText() : null; - int status = full.hasNonNull("status") ? full.get("status").asInt() : 0; - - // Удаляем базовые поля и payload из "полного" объекта, - // всё остальное отправляем внутрь payload. - full.remove("op"); - full.remove("requestId"); - full.remove("status"); - full.remove("payload"); - - ObjectNode root = JSON_MAPPER.createObjectNode(); - if (op != null) root.put("op", op); else root.putNull("op"); - if (requestId != null) root.put("requestId", requestId); else root.putNull("requestId"); - root.put("status", status); - - // payload — это всё, что осталось от full (может быть пустым объектом {}) - root.set("payload", full); - - return JSON_MAPPER.writeValueAsString(root); - - } catch (Exception e) { - // Совсем аварийный случай — сериализация ответа сломалась. - log.error("❌ Response serialization error (op={}, requestId={})", - safe(response != null ? response.getOp() : null), - safe(response != null ? response.getRequestId() : null), - e); - - return "{\"op\":\"" + safe(response != null ? response.getOp() : null) + - "\",\"requestId\":\"" + safe(response != null ? response.getRequestId() : null) + - "\",\"status\":" + (response != null ? response.getStatus() : 500) + - ",\"payload\":{\"code\":\"SERIALIZATION_ERROR\",\"message\":\"Ошибка сериализации ответа\"}}"; - } - } - - private static String safe(String s) { - return s != null ? s : ""; - } - - private static String shorten(String s, int max) { - if (s == null) return ""; - if (s.length() <= max) return s; - return s.substring(0, Math.max(0, max)) + "...(+" + (s.length() - max) + " chars)"; - } - - private static String safeToString(Object o) { - if (o == null) return "null"; - try { - // Чтобы не плодить огромные логи и не утыкаться в циклические ссылки — - // логируем как JSON, если возможно. - return JSON_MAPPER.writeValueAsString(o); - } catch (Exception ignore) { - return String.valueOf(o); - } - } -} -package server.logic.ws_protocol.JSON.utils; - -import shine.db.entities.SolanaUserEntry; -import utils.crypto.Ed25519Util; - -import java.nio.charset.StandardCharsets; -import java.util.Base64; - -public final class AuthSignatures { - - private AuthSignatures() {} - - /** preimage для CreateAuthSession(v2): "AUTH_CREATE_SESSION:login:timeMs:authNonce" */ - public static byte[] preimageCreateAuthSession(String login, long timeMs, String authNonce) { - String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce; - return preimageStr.getBytes(StandardCharsets.UTF_8); - } - - /** Декод base64 / base64url (если надо — подстрой под твой decodeBase64Any) */ - public static byte[] decodeBase64Any(String s) throws IllegalArgumentException { - if (s == null) throw new IllegalArgumentException("base64 is null"); - String x = s.trim(); - if (x.isEmpty()) throw new IllegalArgumentException("base64 is empty"); - - try { - return Base64.getDecoder().decode(x); - } catch (IllegalArgumentException e1) { - // пробуем base64url без паддинга - return Base64.getUrlDecoder().decode(x); - } - } - - /** - * Проверка подписи CreateAuthSession(v2) по deviceKey пользователя. - * Подпись проверяется над preimageCreateAuthSession(...). - */ - public static boolean verifyCreateAuthSessionSignature( - SolanaUserEntry user, - String login, - String authNonce, - long timeMs, - String signatureB64 - ) throws IllegalArgumentException { - - // user.getDeviceKey() — base64 публичного ключа (32 байта) - byte[] publicKey32 = decodeBase64Any(user.getDeviceKey()); - byte[] signature64 = decodeBase64Any(signatureB64); - - byte[] preimage = preimageCreateAuthSession(login, timeMs, authNonce); - return Ed25519Util.verify(preimage, signature64, publicKey32); - } -} -package server.logic.ws_protocol.JSON.utils; - -import server.logic.ws_protocol.JSON.entyties.Net_Exception_Response; -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Фабрика ошибок для JSON-протокола. - * Создаёт единообразные NetExceptionResponse. - */ -public final class NetExceptionResponseFactory { - - private NetExceptionResponseFactory() { - // запрет на создание объектов - } - - public static Net_Exception_Response error(Net_Request req, - int status, - String code, - String message) { - - Net_Exception_Response resp = new Net_Exception_Response(); - - // ✅ НЕ падаем, даже если req == null - if (req != null) { - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - } else { - resp.setOp(null); - resp.setRequestId(null); - } - - resp.setStatus(status); - resp.setCode(code); - resp.setMessage(message); - return resp; - } - - /** - * Вариант для случаев, когда NetRequest ещё не распарсен, - * но мы уже знаем op и requestId (или они null). - */ - public static Net_Exception_Response error(String op, - String requestId, - int status, - String code, - String message) { - - Net_Exception_Response resp = new Net_Exception_Response(); - resp.setOp(op); - resp.setRequestId(requestId); - resp.setStatus(status); - resp.setCode(code); - resp.setMessage(message); - return resp; - } -} -package server.logic.ws_protocol; - -/** - * WireCodes — константы бинарного протокола поверх WebSocket. - *. - * Формат входящего сообщения: - * [4] int opCode (big-endian) - * [*] payload - *. - * Ответ сервера: - * ровно [4] int statusCode (big-endian) - */ -public final class WireCodes { - private WireCodes() {} - - public static final class Op { - public static final int PING = 0; - public static final int ADD_BLOCK = 1; - public static final int GET_BLOCKCHAIN = 2; - public static final int SEARCH_USERS = 30; - public static final int GET_LAST_BLOCK_INFO = 31; - private Op() {} - } - - public static final class Status { - public static final int PONG = 100; // ответ на PING -// public static final int OK = 200; // успех - - public static final int ALREADY_EXISTS = 409; // пришёл блок < N+1 - public static final int NON_SEQUENTIAL = 412; // пришёл блок > N+1 - public static final int NOT_FOUND = 422; // Нет такого полбзователя - типо добавляем блок к которому нет пользователя - хотя на деле такой статус наверное никогда не вернётся, тк это раньше проверяется - - - private Status() {} - - - - - // ============================================================ - // 🟢 УСПЕШНЫЕ ОПЕРАЦИИ - // ============================================================ - - /** ✅ Блок успешно добавлен в цепочку. */ - public static final int OK = 200; - - /** 🌱 Создана новая цепочка (первый блок-заголовок принят). */ - public static final int CHAIN_CREATED = 201; - - /** - * 🔁 Такой блок уже существует. - * Клиент может считать это успешным ответом: - * - сервер возвращает 8 байт: [4] код (202) + [4] номер последнего блока (int) - * - клиент обновляет свой lastBlockNumber и не пересылает этот блок снова. */ - public static final int BLOCK_ALREADY_EXISTS = 202; // плюс к кодуследом возвращается номер последнего блока на сервере - - - // ============================================================ - // 🟡 ЛОГИЧЕСКИЕ / ПРОТОКОЛЬНЫЕ ОШИБКИ - // ============================================================ - - /** ⚠️ Нарушена последовательность — пришёл блок с номером > ожидаемого. - * Сервер вернёт 8 байт: [4] код (409) + [4] последний номер блока. - * Клиент должен дослать недостающие блоки. */ - public static final int OUT_OF_SEQUENCE = 409; // плюс к кодуследом возвращается номер последнего блока на сервере - - /** ❌ Некорректные или неполные данные в запросе. */ - public static final int BAD_REQUEST = 400; - - /** 🚫 Цепочка с указанным blockchainId не найдена. */ - public static final int CHAIN_NOT_FOUND = 404; - - /** 🧩 Несовпадение blockchainId между заголовком блока и телом. */ - public static final int INVALID_BLOCKCHAIN_ID = 421; - - /** ❌ Ошибка верификации блока — хэш или подпись не совпали. - * 🔐 Ошибка хэша: SHA-256(preimage) не совпал с переданным hash32. - * 🔏 Ошибка подписи Ed25519 — блок не прошёл криптографическую проверку. */ - public static final int UNVERIFIED = 422; - - - /** 🙅 Некорректный логин (пустой, неверный формат, недопустимые символы). По сути вообще не может быть, тк логин проверяют при создании в другом блокчейне*/ - public static final int BAD_LOGIN = 462; - - - // ============================================================ - // 🔴 СИСТЕМНЫЕ ОШИБКИ / ОГРАНИЧЕНИЯ - // ============================================================ - - // ============================================================ - // 🔴 СИСТЕМНЫЕ ОШИБКИ / ОГРАНИЧЕНИЯ - // ============================================================ - - /** 💾 Достигнут лимит размера блокчейна. */ - public static final int BLOCKCHAIN_FULL = 507; - - /** 🧱 Ошибка при сохранении или обновлении данных на сервере (файлы, JSON и т.п.). */ - public static final int SERVER_DATA_ERROR = 501; - - /** 💥 Общая внутренняя ошибка сервера (необработанное исключение). */ - public static final int INTERNAL_ERROR = 500; - } - -} - -package server.ws; - -import org.eclipse.jetty.websocket.api.Session; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -import server.logic.ws_protocol.JSON.ConnectionContext; -import shine.db.entities.SolanaUserEntry; - -import java.net.SocketAddress; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Утилита для работы с WebSocket-подключениями. - * - * Цель этой версии: - * - всегда логировать "кто закрыл" / "что закрывали" / "в каком состоянии был WS"; - * - логировать исключения так, чтобы было видно первопричину; - * - не терять контекст из-за ctx.reset() (сначала снимаем "снимок" полей). - */ -public final class WsConnectionUtils { - - private static final Logger log = LoggerFactory.getLogger(WsConnectionUtils.class); - - /** Счётчик событий закрытия (удобно коррелировать логи). */ - private static final AtomicLong CLOSE_SEQ = new AtomicLong(0); - - private WsConnectionUtils() { - // utility - } - - public static void closeConnection(ConnectionContext ctx, int statusCode, String reason) { - closeConnection(ctx, statusCode, reason, null, "UNKNOWN"); - } - - /** - * Расширенное закрытие с указанием инициатора и причины (Throwable). - * - * @param ctx контекст - * @param statusCode код закрытия - * @param reason причина (пойдёт в close frame + логи) - * @param cause исключение/первопричина (если закрываем из catch) - * @param initiator строка "кто инициировал" (handler/op/requestId/etc.) - */ - public static void closeConnection(ConnectionContext ctx, - int statusCode, - String reason, - Throwable cause, - String initiator) { - if (ctx == null) return; - - final long closeId = CLOSE_SEQ.incrementAndGet(); - - // --- СНИМОК КОНТЕКСТА ДО reset() --- - final Session ws = ctx.getWsSession(); - - final String sessionId = safeString(ctx.getSessionId()); - final int authStatus = safeAuthStatus(ctx); - - final SolanaUserEntry user = ctx.getSolanaUser(); - final String login = (user != null ? safeString(user.getLogin()) : ""); - - final String activeSessionId = - (ctx.getActiveSession() != null ? safeString(ctx.getActiveSession().getSessionId()) : ""); - - final boolean wsPresent = (ws != null); - final boolean wsOpen = (ws != null && safeIsOpen(ws)); - final String wsInfo = formatWsInfo(ws); - - final String threadName = Thread.currentThread().getName(); - final int ctxId = System.identityHashCode(ctx); - - // Логируем "начало закрытия" всегда, чтобы видеть даже случаи "ws уже закрыт" - if (cause != null) { - log.warn("WS_CLOSE#{} BEGIN initiator={} thread={} ctxId={} login={} sessionId={} activeSessionId={} authStatus={} statusCode={} reason={} wsPresent={} wsOpen={} wsInfo={}", - closeId, initiator, threadName, ctxId, login, sessionId, activeSessionId, authStatus, statusCode, reason, wsPresent, wsOpen, wsInfo, cause); - } else { - log.info("WS_CLOSE#{} BEGIN initiator={} thread={} ctxId={} login={} sessionId={} activeSessionId={} authStatus={} statusCode={} reason={} wsPresent={} wsOpen={} wsInfo={}", - closeId, initiator, threadName, ctxId, login, sessionId, activeSessionId, authStatus, statusCode, reason, wsPresent, wsOpen, wsInfo); - } - - // --- ШАГ 1: убрать из реестра (чтобы новые сообщения не шли в мёртвый контекст) --- - try { - ActiveConnectionsRegistry.getInstance().remove(ctx); - log.debug("WS_CLOSE#{} registry.remove OK ctxId={} sessionId={} login={}", closeId, ctxId, sessionId, login); - } catch (Exception e) { - log.warn("WS_CLOSE#{} registry.remove FAIL ctxId={} sessionId={} login={}", closeId, ctxId, sessionId, login, e); - } - - // --- ШАГ 2: закрыть WS (если открыт) --- - if (ws != null) { - if (safeIsOpen(ws)) { - try { - ws.close(statusCode, safeString(reason)); - log.info("WS_CLOSE#{} ws.close OK ctxId={} sessionId={} login={} statusCode={} reason={}", - closeId, ctxId, sessionId, login, statusCode, reason); - } catch (Exception e) { - log.warn("WS_CLOSE#{} ws.close FAIL ctxId={} sessionId={} login={} statusCode={} reason={} wsInfo={}", - closeId, ctxId, sessionId, login, statusCode, reason, wsInfo, e); - } - } else { - log.info("WS_CLOSE#{} ws already closed ctxId={} sessionId={} login={} wsInfo={}", - closeId, ctxId, sessionId, login, wsInfo); - } - } - - // --- ШАГ 3: очистить контекст (в конце, чтобы не потерять поля в логах выше) --- - try { - ctx.reset(); - log.debug("WS_CLOSE#{} ctx.reset OK ctxId={} (was sessionId={}, login={})", closeId, ctxId, sessionId, login); - } catch (Exception e) { - log.warn("WS_CLOSE#{} ctx.reset FAIL ctxId={} (was sessionId={}, login={})", closeId, ctxId, sessionId, login, e); - } - - log.info("WS_CLOSE#{} END initiator={} ctxId={} sessionId={} login={}", closeId, initiator, ctxId, sessionId, login); - } - - private static String safeString(String s) { - return (s == null ? "" : s); - } - - private static int safeAuthStatus(ConnectionContext ctx) { - try { - return ctx.getAuthenticationStatus(); - } catch (Exception e) { - return -999; - } - } - - private static boolean safeIsOpen(Session ws) { - try { - return ws.isOpen(); - } catch (Exception e) { - return false; - } - } - - private static String formatWsInfo(Session ws) { - if (ws == null) return "null"; - - String remote = ""; - String local = ""; - try { - SocketAddress ra = ws.getRemoteAddress(); - remote = (ra != null ? ra.toString() : ""); - } catch (Exception ignored) { } - - try { - SocketAddress la = ws.getLocalAddress(); - local = (la != null ? la.toString() : ""); - } catch (Exception ignored) { } - - return "remote=" + remote + ", local=" + local; - } -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/concat_to_file.sh b/SHiNE-server/shine-server-net-protocol/src/main/java/server/concat_to_file.sh deleted file mode 100755 index f6db1f1..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/concat_to_file.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# собрать только *.java файлы и вывести их содержимое в файл -find . -type f -name "*.java" | sort | while read -r f; do - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Все .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java index 518e9c8..742c851 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ConnectionContext.java @@ -10,7 +10,7 @@ import shine.db.entities.ActiveSessionEntry; * * Важно (v2): * - Авторизация всегда 2 шага: - * A) Создание новой сессии через deviceKey: + * A) Создание новой сессии через clientKey: * AuthChallenge(login) -> ctx.authNonce * CreateAuthSession(...) -> ctx.AUTH_STATUS_USER + ctx.activeSession * @@ -39,7 +39,7 @@ public class ConnectionContext { /** * Одноразовый nonce, выданный на шаге 1 (AuthChallenge), - * используется на шаге CreateAuthSession для проверки подписи deviceKey. + * используется на шаге CreateAuthSession для проверки подписи clientKey. */ private String authNonce; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/all_files.txt b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/all_files.txt deleted file mode 100644 index 59f8d1b..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/all_files.txt +++ /dev/null @@ -1,4548 +0,0 @@ -package server.logic.ws_protocol.JSON; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArraySet; - -/** - * Реестр активных подключений (только авторизованные). - */ -public final class ActiveConnectionsRegistry { - - private static final Logger log = LoggerFactory.getLogger(ActiveConnectionsRegistry.class); - - private static final ActiveConnectionsRegistry INSTANCE = new ActiveConnectionsRegistry(); - - public static ActiveConnectionsRegistry getInstance() { - return INSTANCE; - } - - private ActiveConnectionsRegistry() { - // singleton - } - - // sessionId (String) -> ConnectionContext - private final ConcurrentHashMap bySessionId = new ConcurrentHashMap<>(); - - // login (String) -> множество ConnectionContext для этого пользователя - private final ConcurrentHashMap> byLogin = new ConcurrentHashMap<>(); - - /** - * Зарегистрировать авторизованное подключение. - * Ожидается, что в ctx уже выставлены login и sessionId. - */ - public void register(ConnectionContext ctx) { - if (ctx == null) return; - - String sessionId = ctx.getSessionId(); - String login = ctx.getLogin(); - - if (sessionId == null || sessionId.isBlank() || login == null || login.isBlank()) { - log.debug("register skipped: bad ctx fields (login='{}', sessionId='{}')", login, sessionId); - return; - } - - // ✅ Если кто-то перерегистрировал тот же sessionId — вычищаем старый ctx из byLogin - ConnectionContext prev = bySessionId.put(sessionId, ctx); - if (prev != null && prev != ctx) { - String prevLogin = prev.getLogin(); - if (prevLogin != null && !prevLogin.isBlank()) { - Set prevSet = byLogin.get(prevLogin); - if (prevSet != null) { - prevSet.remove(prev); - if (prevSet.isEmpty()) { - byLogin.remove(prevLogin); - } - } - } - log.warn("sessionId reused: replaced previous ctx (sessionId={}, prevLogin={}, newLogin={})", - sessionId, prevLogin, login); - } - - byLogin - .computeIfAbsent(login, id -> new CopyOnWriteArraySet<>()) - .add(ctx); - - log.debug("registered ctx (login={}, sessionId={})", login, sessionId); - } - - /** - * Удалить подключение по контексту (например, при onClose). - */ - public void remove(ConnectionContext ctx) { - if (ctx == null) return; - - String sessionId = ctx.getSessionId(); - String login = ctx.getLogin(); - - if (sessionId != null && !sessionId.isBlank()) { - ConnectionContext removed = bySessionId.remove(sessionId); - - // Если в мапе лежал другой ctx под тем же sessionId — не трогаем его byLogin - if (removed != null && removed != ctx) { - log.debug("remove(ctx): sessionId mapped to another ctx, skip byLogin cleanup (sessionId={})", sessionId); - return; - } - } - - if (login != null && !login.isBlank()) { - Set set = byLogin.get(login); - if (set != null) { - set.remove(ctx); - if (set.isEmpty()) { - byLogin.remove(login); - } - } - } - - log.debug("removed ctx (login={}, sessionId={})", login, sessionId); - } - - /** - * Удалить подключение по sessionId. - */ - public void removeBySessionId(String sessionId) { - if (sessionId == null || sessionId.isBlank()) return; - - ConnectionContext ctx = bySessionId.remove(sessionId); - if (ctx == null) return; - - String login = ctx.getLogin(); - if (login != null && !login.isBlank()) { - Set set = byLogin.get(login); - if (set != null) { - set.remove(ctx); - if (set.isEmpty()) { - byLogin.remove(login); - } - } - } - - log.debug("removed by sessionId (login={}, sessionId={})", login, sessionId); - } - - /** - * Получить контекст по sessionId. - */ - public ConnectionContext getBySessionId(String sessionId) { - if (sessionId == null || sessionId.isBlank()) return null; - return bySessionId.get(sessionId); - } - - /** - * Получить все активные подключения пользователя по login. - */ - public Set getByLogin(String login) { - if (login == null || login.isBlank()) return Set.of(); - Set set = byLogin.get(login); - return (set == null) ? Set.of() : set; // CopyOnWriteArraySet можно отдавать как есть - } -} -package server.logic.ws_protocol.JSON; - -import org.eclipse.jetty.websocket.api.Session; -import shine.db.entities.SolanaUserEntry; -import shine.db.entities.ActiveSessionEntry; - -/** - * ConnectionContext — контекст состояния одного WebSocket-соединения. - * Живёт ровно столько же, сколько живёт подключение. - * - * Важно (v2): - * - Авторизация всегда 2 шага: - * A) Создание новой сессии через deviceKey: - * AuthChallenge(login) -> ctx.authNonce - * CreateAuthSession(...) -> ctx.AUTH_STATUS_USER + ctx.activeSession - * - * B) Вход в существующую сессию через sessionKey: - * SessionChallenge(sessionId) -> ctx.sessionLoginNonce + ctx.sessionLoginSessionId + expiresAt - * SessionLogin(...) -> проверка подписи sessionKey по pubkey из БД -> ctx.AUTH_STATUS_USER - */ -public class ConnectionContext { - - // Статусы аутентификации - public static final int AUTH_STATUS_NONE = 0; // анонимный / не авторизован - public static final int AUTH_STATUS_AUTH_IN_PROGRESS = 1; // выполнен challenge (AuthChallenge или SessionChallenge) - public static final int AUTH_STATUS_USER = 2; // авторизованный пользователь - - // Полный пользователь из БД (solana_users) - private SolanaUserEntry solanaUserEntry; - - // Активная сессия из БД (active_sessions) - private ActiveSessionEntry activeSessionEntry; - - /** - * Идентификатор сессии — base64-строка от 32 байт. - * Заполняется после успешного входа (AUTH_STATUS_USER). - */ - private String sessionId; - - /** - * Одноразовый nonce, выданный на шаге 1 (AuthChallenge), - * используется на шаге CreateAuthSession для проверки подписи deviceKey. - */ - private String authNonce; - - /* ===================== SessionLogin challenge (v2) ===================== */ - - /** - * Одноразовый nonce, выданный на шаге SessionChallenge(sessionId), - * используется на шаге SessionLogin для проверки подписи sessionKey. - */ - private String sessionLoginNonce; - - /** - * sessionId, для которого был выдан sessionLoginNonce. - * Нужен, чтобы SessionLogin не мог "подставить" другой sessionId. - */ - private String sessionLoginSessionId; - - /** - * Время истечения sessionLoginNonce (мс с 1970-01-01). - * Если текущее время > expiresAt, то nonce считается недействительным. - */ - private long sessionLoginNonceExpiresAtMs; - - /* ====================================================================== */ - - /** - * Текущий статус аутентификации. - * См. константы AUTH_STATUS_* - */ - private int authenticationStatus = AUTH_STATUS_NONE; - - /** - * WebSocket-сессия Jetty для данного подключения. - * Нужна, чтобы через ConnectionContext можно было отправлять сообщения клиенту. - */ - private Session wsSession; - - // --- WebSocket Session --- - - public Session getWsSession() { - return wsSession; - } - - public void setWsSession(Session wsSession) { - this.wsSession = wsSession; - } - - // --- SolanaUser / ActiveSession --- - - public SolanaUserEntry getSolanaUser() { - return solanaUserEntry; - } - - public void setSolanaUser(SolanaUserEntry solanaUserEntry) { - this.solanaUserEntry = solanaUserEntry; - } - - public ActiveSessionEntry getActiveSession() { - return activeSessionEntry; - } - - public void setActiveSession(ActiveSessionEntry activeSessionEntry) { - this.activeSessionEntry = activeSessionEntry; - } - - // --- Удобный геттер для логина --- - - public String getLogin() { - return solanaUserEntry != null ? solanaUserEntry.getLogin() : null; - } - - // --- sessionId --- - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } - - // --- authNonce --- - - public String getAuthNonce() { - return authNonce; - } - - public void setAuthNonce(String authNonce) { - this.authNonce = authNonce; - } - - // --- sessionLoginNonce (v2) --- - - public String getSessionLoginNonce() { - return sessionLoginNonce; - } - - public void setSessionLoginNonce(String sessionLoginNonce) { - this.sessionLoginNonce = sessionLoginNonce; - } - - public String getSessionLoginSessionId() { - return sessionLoginSessionId; - } - - public void setSessionLoginSessionId(String sessionLoginSessionId) { - this.sessionLoginSessionId = sessionLoginSessionId; - } - - public long getSessionLoginNonceExpiresAtMs() { - return sessionLoginNonceExpiresAtMs; - } - - public void setSessionLoginNonceExpiresAtMs(long sessionLoginNonceExpiresAtMs) { - this.sessionLoginNonceExpiresAtMs = sessionLoginNonceExpiresAtMs; - } - - // --- auth status --- - - public int getAuthenticationStatus() { - return authenticationStatus; - } - - public void setAuthenticationStatus(int authenticationStatus) { - this.authenticationStatus = authenticationStatus; - } - - public boolean isAuthenticatedUser() { - return authenticationStatus == AUTH_STATUS_USER; - } - - public boolean isAnonymous() { - return authenticationStatus == AUTH_STATUS_NONE; - } - - public void reset() { - solanaUserEntry = null; - activeSessionEntry = null; - - sessionId = null; - authNonce = null; - - sessionLoginNonce = null; - sessionLoginSessionId = null; - sessionLoginNonceExpiresAtMs = 0; - - authenticationStatus = AUTH_STATUS_NONE; - wsSession = null; - } - - @Override - public String toString() { - return "ConnectionContext{" + - "login='" + getLogin() + '\'' + - ", sessionId=" + sessionId + - ", authenticationStatus=" + authenticationStatus + - '}'; - } -} -package server.logic.ws_protocol.JSON.entyties; - -/** - * Базовый класс для всех событий (event). - * Общие поля: op и payload. - *. - * Формат JSON (event): - * { - * "op": "...", - * "payload": { ... } - * } - */ -public abstract class Net_Event { - - /** Имя операции / события (op). */ - private String op; - - /** - * Произвольные данные. - * В JSON это поле "payload". - */ - private Object payload; - - // --- getters / setters --- - - public String getOp() { - return op; - } - - public void setOp(String op) { - this.op = op; - } - - public Object getPayload() { - return payload; - } - - public void setPayload(Object payload) { - this.payload = payload; - } -} - -package server.logic.ws_protocol.JSON.entyties; - -/** - * Ответ с ошибкой (любой отказ). - *. - * В payload будет: - * { - * "code": "...", - * "message": "..." - * } - */ -public class Net_Exception_Response extends Net_Response { - - private String code; - private String message; - - public String getCode() { - return code; - } - - public void setCode(String code) { - this.code = code; - } - - public String getMessage() { - return message; - } - - public void setMessage(String message) { - this.message = message; - } -} - -package server.logic.ws_protocol.JSON.entyties; - -/** - * Базовый класс для всех запросов (client → server). - *. - * Наследуется от NetEvent и добавляет requestId. - *. - * Формат JSON (request): - * { - * "op": "...", - * "requestId": "...", - * "payload": { ... } - * } - */ -public abstract class Net_Request extends Net_Event { - - /** Идентификатор запроса, чтобы связать запрос и ответ. */ - private String requestId; - - // --- getters / setters --- - - public String getRequestId() { - return requestId; - } - - public void setRequestId(String requestId) { - this.requestId = requestId; - } -} - -package server.logic.ws_protocol.JSON.entyties; - -/** - * Базовый класс для всех ответов (server → client). - *. - * Наследуется от NetRequest и добавляет status. - *. - * Формат JSON (response): - * { - * "op": "...", - * "requestId": "...", - * "status": 200, - * "payload": { ... } // и для успеха, и для ошибки - * } - */ -public abstract class Net_Response extends Net_Request { - - /** Статус результата (200 — успех, любое другое значение — ошибка). */ - private int status; - - // --- getters / setters --- - - public int getStatus() { - return status; - } - - public void setStatus(int status) { - this.status = status; - } - - public boolean isOk() { - return status == 200; - } -} - -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 1 авторизации: запрос выдачи одноразового nonce (authNonce). - * - * Клиент по логину просит сервер сгенерировать случайный authNonce, - * который будет использован на втором шаге при подписи. - * - * Формат входящего JSON: - * { - * "op": "AuthChallenge", - * "requestId": "...", - * "payload": { - * "login": "someLogin" - * } - * } - * - * Формат успешного ответа: - * { - * "op": "AuthChallenge", - * "requestId": "...", - * "status": 200, - * "payload": { - * "authNonce": "base64-строка-от-32-байт" - * } - * } - */ -public class Net_AuthChallenge_Request extends Net_Request { - - /** - * Логин пользователя, для которого запускается авторизация. - */ - private String login; - - public String getLogin() { - return login; - } - public void setLogin(String login) { - this.login = login; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на AuthChallenge. - * - * При успехе сервер возвращает одноразовый nonce для подписи (authNonce), - * который клиент обязан использовать на втором шаге при формировании строки - * для цифровой подписи. - * - * JSON: - * { - * "op": "AuthChallenge", - * "requestId": "...", - * "status": 200, - * "payload": { - * "authNonce": "base64-строка-от-32-байт" - * } - * } - */ -public class Net_AuthChallenge_Response extends Net_Response { - - /** - * Одноразовый nonce для авторификации. - * Строка — это base64-представление 32 случайных байт. - */ - private String authNonce; - - public String getAuthNonce() { - return authNonce; - } - - public void setAuthNonce(String authNonce) { - this.authNonce = authNonce; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос CloseActiveSession — закрытие активной сессии пользователя. - * - * Новая логика (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Никаких подписей и "AUTH_IN_PROGRESS" здесь больше нет. - * - * payload: - * { - * "sessionId": "..." // опционально; если пусто — закрываем текущую - * } - */ -public class Net_CloseActiveSession_Request extends Net_Request { - - /** Идентификатор сессии, которую нужно закрыть. Может быть пустым. */ - private String sessionId; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на CloseActiveSession. - * - * При успехе: - * - status = 200; - * - payload = {}. - * - * Закрытие WebSocket-соединения может быть выполнено сразу (для другой сессии) - * или чуть позже (для текущей сессии) после отправки ответа. - */ -public class Net_CloseActiveSession_Response extends Net_Response { - // Дополнительных полей пока не требуется. -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 2 (v2): создание новой сессии ТОЛЬКО через deviceKey. - * - * Шаги: - * 1) AuthChallenge(login) -> authNonce - * 2) CreateAuthSession(storagePwd, sessionPubKeyB64, timeMs, signatureB64, clientInfo) - * - * Подпись deviceKey делается над строкой (UTF-8): - * AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce}:{sessionPubKeyB64}:{storagePwd} - * - * Важно: - * - sessionKey генерируется на клиенте, на сервер отправляется ТОЛЬКО sessionPubKeyB64 (32 bytes base64). - * - В БД active_sessions.session_key хранится sessionPubKeyB64. - */ -public class Net_CreateAuthSession_Request extends Net_Request { - - /** Клиентский пароль для хранения данных (base64 от 32 байт). */ - private String storagePwd; - - /** Публичный ключ сессии (sessionPubKey), base64 от 32 байт. */ - private String sessionPubKeyB64; - - /** Время на стороне клиента (мс с 1970-01-01). */ - private long timeMs; - - /** Подпись Ed25519(deviceKey) над строкой AUTH_CREATE_SESSION:... (base64). */ - private String signatureB64; - - /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ - private String clientInfo; - - public String getStoragePwd() { - return storagePwd; - } - - public void setStoragePwd(String storagePwd) { - this.storagePwd = storagePwd; - } - - public String getSessionPubKeyB64() { - return sessionPubKeyB64; - } - - public void setSessionPubKeyB64(String sessionPubKeyB64) { - this.sessionPubKeyB64 = sessionPubKeyB64; - } - - public long getTimeMs() { - return timeMs; - } - - public void setTimeMs(long timeMs) { - this.timeMs = timeMs; - } - - public String getSignatureB64() { - return signatureB64; - } - - public void setSignatureB64(String signatureB64) { - this.signatureB64 = signatureB64; - } - - public String getClientInfo() { - return clientInfo; - } - - public void setClientInfo(String clientInfo) { - this.clientInfo = clientInfo; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на CreateAuthSession (v2). - * - * При успехе сервер создаёт запись в active_sessions - * и возвращает идентификатор сессии sessionId. - * - * JSON: - * { - * "op": "CreateAuthSession", - * "requestId": "...", - * "status": 200, - * "payload": { - * "sessionId": "base64(32)" - * } - * } - */ -public class Net_CreateAuthSession_Response extends Net_Response { - - /** Идентификатор сессии, base64 от 32 байт. */ - private String sessionId; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос ListSessions — список активных сессий пользователя. - * - * Новая логика (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Пустой payload. - */ -public class Net_ListSessions_Request extends Net_Request { - // пусто -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.List; - -/** - * Ответ на ListSessions. - * - * При успехе: - * - status = 200; - * - payload: - * { - * "sessions": [ - * { - * "sessionId": "...", - * "clientInfoFromClient": "...", - * "clientInfoFromRequest": "...", - * "geo": "Country, City" | "unknown", - * "lastAuthirificatedAtMs": 1733310000000 - * }, - * ... - * ] - * } - */ -public class Net_ListSessions_Response extends Net_Response { - - /** - * Список активных сессий для текущего пользователя. - */ - private List sessions; - - public List getSessions() { - return sessions; - } - - public void setSessions(List sessions) { - this.sessions = sessions; - } - - /** - * Описание одной активной сессии. - */ - public static class SessionInfo { - - /** Идентификатор сессии, base64 от 32 байт. */ - private String sessionId; - - /** Что прислал клиент в CreateAuthSession/RefreshSession (clientInfo). */ - private String clientInfoFromClient; - - /** Краткая строка, собранная сервером из HTTP-запроса (UA, платформа и т.п.). */ - private String clientInfoFromRequest; - - /** Строка геолокации вида "Country, City" или "unknown". */ - private String geo; - - /** Время последней успешной авторизации/refresh (мс с 1970-01-01). */ - private long lastAuthirificatedAtMs; - - // --- getters / setters --- - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } - - public String getClientInfoFromClient() { - return clientInfoFromClient; - } - - public void setClientInfoFromClient(String clientInfoFromClient) { - this.clientInfoFromClient = clientInfoFromClient; - } - - public String getClientInfoFromRequest() { - return clientInfoFromRequest; - } - - public void setClientInfoFromRequest(String clientInfoFromRequest) { - this.clientInfoFromRequest = clientInfoFromRequest; - } - - public String getGeo() { - return geo; - } - - public void setGeo(String geo) { - this.geo = geo; - } - - public long getLastAuthirificatedAtMs() { - return lastAuthirificatedAtMs; - } - - public void setLastAuthirificatedAtMs(long lastAuthirificatedAtMs) { - this.lastAuthirificatedAtMs = lastAuthirificatedAtMs; - } - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 1 входа в существующую сессию (v2): - * SessionChallenge(sessionId) -> nonce - */ -public class Net_SessionChallenge_Request extends Net_Request { - - private String sessionId; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на SessionChallenge (v2). - * payload: { "nonce": "base64(32)" } - */ -public class Net_SessionChallenge_Response extends Net_Response { - - private String nonce; - - public String getNonce() { - return nonce; - } - - public void setNonce(String nonce) { - this.nonce = nonce; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 2 входа в существующую сессию (v2): - * SessionLogin(sessionId, timeMs, signatureB64) -> storagePwd, AUTH_STATUS_USER - * - * Подпись делается sessionKey (приватный ключ на устройстве) над строкой (UTF-8): - * SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} - * - * nonce берётся из SessionChallenge и хранится в ctx (одноразовый, TTL). - */ -public class Net_SessionLogin_Request extends Net_Request { - - private String sessionId; - private long timeMs; - private String signatureB64; - - /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ - private String clientInfo; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } - - public long getTimeMs() { - return timeMs; - } - - public void setTimeMs(long timeMs) { - this.timeMs = timeMs; - } - - public String getSignatureB64() { - return signatureB64; - } - - public void setSignatureB64(String signatureB64) { - this.signatureB64 = signatureB64; - } - - public String getClientInfo() { - return clientInfo; - } - - public void setClientInfo(String clientInfo) { - this.clientInfo = clientInfo; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на SessionLogin (v2). - * payload: { "storagePwd": "base64(32)" } - */ -public class Net_SessionLogin_Response extends Net_Response { - - private String storagePwd; - - public String getStoragePwd() { - return storagePwd; - } - - public void setStoragePwd(String storagePwd) { - this.storagePwd = storagePwd; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import server.logic.ws_protocol.Base64Ws; -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_AuthChallenge_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; - -import java.security.SecureRandom; - -/** - * AuthChallenge (v2) — шаг 1 создания новой сессии. - * - * Логика авторизации (v2): - * - Создание новой сессии возможно ТОЛЬКО через deviceKey пользователя. - * - Этот handler выдаёт одноразовый authNonce, который клиент использует во втором шаге: - * CreateAuthSession(..., signature(deviceKey, AUTH_CREATE_SESSION:...)) - * - * Что делает: - * 1) Проверяет login. - * 2) Находит пользователя (solana_users). - * 3) Пишет solanaUser в ctx, ставит AUTH_STATUS_AUTH_IN_PROGRESS. - * 4) Генерирует authNonce (base64url(32)) и сохраняет в ctx.authNonce. - */ -public class Net_AuthChallenge_Handler implements JsonMessageHandler { - - private static final SecureRandom RANDOM = new SecureRandom(); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - - Net_AuthChallenge_Request req = (Net_AuthChallenge_Request) baseReq; - - String login = req.getLogin(); - if (login == null || login.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_LOGIN", - "Пустой логин" - ); - } - - // Если по этому соединению уже есть залогиненный пользователь — не даём повторную авторификацию - if (ctx.getLogin() != null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "ALREADY_AUTHED", - "Попытка повторной авторификации для уже заданного login=" + ctx.getLogin() - ); - } - - SolanaUserEntry solanaUserEntry = SolanaUsersDAO.getInstance().getByLogin(login); - if (solanaUserEntry == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "UNKNOWN_USER", - "Пользователь с таким логином не найден" - ); - } - - ctx.setSolanaUser(solanaUserEntry); - ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS); - - byte[] buf = new byte[32]; - RANDOM.nextBytes(buf); - String authNonce = Base64Ws.encode(buf); - - ctx.setAuthNonce(authNonce); - - Net_AuthChallenge_Response resp = new Net_AuthChallenge_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setAuthNonce(authNonce); - - return resp; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -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_CloseActiveSession_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import server.ws.WsConnectionUtils; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; - -import java.sql.SQLException; - -/** - * CloseActiveSession (v2) — закрытие текущей или другой сессии. - * - * Логика авторизации (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Никаких подписей и AUTH_IN_PROGRESS здесь больше нет. - * - * Закрытие: - * - удаляем запись из БД - * - если по sessionId есть активный WS — закрываем его - */ -public class Net_CloseActiveSession_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_CloseActiveSession_Handler.class); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_CloseActiveSession_Request req = (Net_CloseActiveSession_Request) baseReq; - - if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "NOT_AUTHENTICATED", - "Операция доступна только для авторизованных пользователей" - ); - } - - SolanaUserEntry user = ctx.getSolanaUser(); - String currentLogin = user.getLogin(); - - String targetSessionId = req.getSessionId(); - if (targetSessionId == null || targetSessionId.isBlank()) { - if (ctx.getSessionId() != null && !ctx.getSessionId().isBlank()) { - targetSessionId = ctx.getSessionId(); - } else if (ctx.getActiveSession() != null && ctx.getActiveSession().getSessionId() != null) { - targetSessionId = ctx.getActiveSession().getSessionId(); - } else { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_SESSION_TO_CLOSE", - "Не удалось определить, какую сессию нужно закрыть" - ); - } - } - - ActiveSessionEntry targetSession; - try { - targetSession = ActiveSessionsDAO.getInstance().getBySessionId(targetSessionId); - } catch (SQLException e) { - log.error("Ошибка БД при поиске сессии для CloseActiveSession sessionId={}", targetSessionId, e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка доступа к базе данных при поиске сессии" - ); - } - - if (targetSession == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_NOT_FOUND", - "Сессия для закрытия не найдена" - ); - } - - if (currentLogin == null || !currentLogin.equals(targetSession.getLogin())) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_OF_ANOTHER_USER", - "Нельзя закрывать сессию другого пользователя" - ); - } - - boolean isCurrentSession = targetSessionId.equals(ctx.getSessionId()); - - closeActiveSession(targetSessionId, ctx, isCurrentSession); - - Net_CloseActiveSession_Response resp = new Net_CloseActiveSession_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - return resp; - } - - private void closeActiveSession(String targetSessionId, - ConnectionContext currentCtx, - boolean isCurrentSession) { - - try { - ActiveSessionsDAO.getInstance().deleteBySessionId(targetSessionId); - } catch (SQLException e) { - log.error("Ошибка БД при удалении сессии sessionId={}", targetSessionId, e); - } - - ConnectionContext ctxToClose = - ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId); - - if (ctxToClose == null) return; - - if (isCurrentSession && ctxToClose == currentCtx) { - new Thread(() -> { - try { Thread.sleep(50); } catch (InterruptedException ignored) {} - WsConnectionUtils.closeConnection( - ctxToClose, - 4000, - "Session closed by client via CloseActiveSession" - ); - }, "CloseSession-" + targetSessionId).start(); - } else { - WsConnectionUtils.closeConnection( - ctxToClose, - 4000, - "Session closed by client via CloseActiveSession" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.Base64Ws; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -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_CreateAuthSession_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import server.ws.WsConnectionUtils; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; -import shine.geo.ClientInfoService; -import shine.geo.GeoLookupService; -import utils.crypto.Ed25519Util; - -import org.eclipse.jetty.websocket.api.Session; - -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.sql.SQLException; - -/** - * CreateAuthSession (v2) — шаг 2 создания новой сессии (ТОЛЬКО deviceKey). - * - * Логика авторизации (v2): - * - Создание сессии: AuthChallenge(login) -> authNonce -> CreateAuthSession(...) - * - Клиент генерирует sessionKey (Ed25519), хранит приватный ключ у себя, - * отправляет на сервер ТОЛЬКО sessionPubKeyB64. - * - Сервер сохраняет sessionPubKeyB64 в active_sessions.session_key. - * - * Подпись deviceKey (Ed25519) проверяется над строкой (UTF-8): - * AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce} - * - * На выходе: - * - создаётся запись active_sessions - * - ctx становится AUTH_STATUS_USER (вход выполнен как "текущая сессия") - * - ответ: sessionId - */ -public class Net_CreateAuthSession__Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class); - private static final SecureRandom RANDOM = new SecureRandom(); - - public static final long ALLOWED_SKEW_MS = 30_000L; - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - - Net_CreateAuthSession_Request req = (Net_CreateAuthSession_Request) baseReq; - - if (ctx == null - || ctx.getSolanaUser() == null - || ctx.getAuthNonce() == null - || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS) { - - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_STEP1_CONTEXT", - "Шаг 1 авторизации не был корректно выполнен для данного соединения" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no step1 context or bad auth state"); - return err; - } - - SolanaUserEntry user = ctx.getSolanaUser(); - String login = user.getLogin(); - if (login == null || login.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "NO_LOGIN", - "Для пользователя не задан login в БД" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no login"); - return err; - } - - String storagePwd = req.getStoragePwd(); - if (storagePwd == null || storagePwd.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_STORAGE_PWD", - "Пустой storagePwd" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty storagePwd"); - return err; - } - - String sessionPubKeyB64 = req.getSessionPubKeyB64(); - if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SESSION_PUBKEY", - "Пустой sessionPubKeyB64" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty session pubkey"); - return err; - } - - // Проверим, что sessionPubKeyB64 декодируется в 32 байта - byte[] sessionPubKey32; - try { - sessionPubKey32 = Base64Ws.decode(sessionPubKeyB64); - } catch (IllegalArgumentException e) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "Некорректный base64 в sessionPubKeyB64" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session pubkey base64"); - return err; - } - if (sessionPubKey32.length != 32) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_SESSION_PUBKEY_LEN", - "sessionPubKey должен быть 32 байта" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session pubkey length"); - return err; - } - - String signatureB64 = req.getSignatureB64(); - if (signatureB64 == null || signatureB64.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SIGNATURE", - "Пустая цифровая подпись" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty signature"); - return err; - } - - long timeMs = req.getTimeMs(); - long nowMs = System.currentTimeMillis(); - long diff = Math.abs(nowMs - timeMs); - if (diff > ALLOWED_SKEW_MS) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "TIME_SKEW", - "Время клиента отличается от сервера более чем на 30 секунд" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: time skew"); - return err; - } - - String clientInfoFromClient = req.getClientInfo(); - if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) { - clientInfoFromClient = clientInfoFromClient.substring(0, 50); - } - - String devicePubKeyB64 = user.getDeviceKey(); - if (devicePubKeyB64 == null || devicePubKeyB64.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_DEVICE_KEY", - "Отсутствует deviceKey у пользователя" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no deviceKey"); - return err; - } - - String authNonce = ctx.getAuthNonce(); - - boolean sigOk; - try { - sigOk = verifyCreateSessionSignature( - user, - login, - authNonce, - timeMs, - signatureB64 - ); - } catch (IllegalArgumentException ex) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "Некорректный формат Base64 для ключа или подписи" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad base64"); - return err; - } - - if (!sigOk) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "BAD_SIGNATURE", - "Подпись не прошла проверку" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad signature"); - return err; - } - - // --- генерируем sessionId --- - String sessionId = generateRandom32B64Url(); - long now = System.currentTimeMillis(); - - // --- Сбор данных о клиенте (IP, UA, язык) --- - Session wsSession = ctx.getWsSession(); - String clientInfoFromRequest = ClientInfoService.buildClientInfoString(wsSession); - String userLanguage = ClientInfoService.extractPreferredLanguageTag(wsSession); - - String clientIp = ""; - if (wsSession != null) { - String ip = ClientInfoService.extractClientIp(wsSession); - if (ip != null) clientIp = ip; - - if (!clientIp.isBlank()) { - try { - GeoLookupService.resolveCountryCityOrIpWithCache(clientIp); - } catch (Exception e) { - log.debug("Geo lookup failed for ip={}", clientIp, e); - } - } - } - - // --- создаём запись ActiveSession и сохраняем в БД --- - ActiveSessionsDAO dao = ActiveSessionsDAO.getInstance(); - ActiveSessionEntry activeSessionEntry; - - try { - activeSessionEntry = new ActiveSessionEntry( - sessionId, - login, - sessionPubKeyB64, // session_key (pubkey) - storagePwd, - now, - now, - null, // pushEndpoint - null, // pushP256dhKey - null, // pushAuthKey - clientIp, - clientInfoFromClient, - clientInfoFromRequest, - userLanguage - ); - - dao.insert(activeSessionEntry); - } catch (SQLException e) { - log.error("Ошибка БД при создании новой сессии для login={}", login, e); - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR_SESSION_CREATE", - "Ошибка БД при создании сессии" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: db error"); - return err; - } - - // --- обновляем контекст --- - ctx.setActiveSession(activeSessionEntry); - ctx.setSessionId(sessionId); - ctx.setAuthNonce(null); - ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); - - ActiveConnectionsRegistry.getInstance().register(ctx); - - // --- формируем ответ --- - Net_CreateAuthSession_Response resp = new Net_CreateAuthSession_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setSessionId(sessionId); - return resp; - } - - private static boolean verifyCreateSessionSignature( - SolanaUserEntry user, - String login, - String authNonce, - long timeMs, - String signatureB64 - ) throws IllegalArgumentException { - - // deviceKey (pub, 32) - byte[] publicKey32 = Ed25519Util.keyFromBase64(user.getDeviceKey()); - byte[] signature64 = Base64Ws.decode(signatureB64); - - String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce; - byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); - - return Ed25519Util.verify(preimage, signature64, publicKey32); - } - - private static String generateRandom32B64Url() { - byte[] buf = new byte[32]; - RANDOM.nextBytes(buf); - return Base64Ws.encode(buf); - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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_ListSessions_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Response; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Response.SessionInfo; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; -import shine.geo.GeoLookupService; - -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.List; - -/** - * ListSessions (v2) — список активных сессий. - * - * Логика авторизации (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Никаких подписей здесь больше нет. - */ -public class Net_ListSessions_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_ListSessions_Handler.class); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_ListSessions_Request req = (Net_ListSessions_Request) baseReq; - - if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "NOT_AUTHENTICATED", - "Операция доступна только для авторизованных пользователей" - ); - } - - SolanaUserEntry user = ctx.getSolanaUser(); - String currentLogin = user.getLogin(); - - List sessions; - try { - sessions = ActiveSessionsDAO.getInstance().getByLogin(currentLogin); - } catch (SQLException e) { - log.error("Ошибка БД при получении списка сессий для login={}", currentLogin, e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR_LIST_SESSIONS", - "Ошибка доступа к базе данных при получении списка сессий" - ); - } - - List resultList = new ArrayList<>(); - for (ActiveSessionEntry s : sessions) { - SessionInfo info = new SessionInfo(); - info.setSessionId(s.getSessionId()); - info.setClientInfoFromClient(s.getClientInfoFromClient()); - info.setClientInfoFromRequest(s.getClientInfoFromRequest()); - info.setLastAuthirificatedAtMs(s.getLastAuthirificatedAtMs()); - - String ip = s.getClientIp(); - String geo = GeoLookupService.resolveCountryCityOrIpWithCache(ip); - info.setGeo(geo); - - resultList.add(info); - } - - Net_ListSessions_Response resp = new Net_ListSessions_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setSessions(resultList); - - return resp; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import server.logic.ws_protocol.Base64Ws; -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_SessionChallenge_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; - -import java.security.SecureRandom; -import java.sql.SQLException; - -/** - * SessionChallenge (v2) — шаг 1 входа в существующую сессию. - * - * Логика авторизации (v2): - * - Вход в существующую сессию ВСЕГДА в 2 шага: - * 1) SessionChallenge(sessionId) -> nonce - * 2) SessionLogin(sessionId, timeMs, signature(sessionKey, SESSION_LOGIN:...)) - * - * Что делает: - * - Проверяет, что sessionId существует в БД. - * - Генерирует одноразовый nonce (base64url(32)), сохраняет его в ctx: - * ctx.sessionLoginNonce, ctx.sessionLoginSessionId, ctx.sessionLoginNonceExpiresAtMs. - */ -public class Net_SessionChallenge_Handler implements JsonMessageHandler { - - private static final SecureRandom RANDOM = new SecureRandom(); - private static final long NONCE_TTL_MS = 60_000L; - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_SessionChallenge_Request req = (Net_SessionChallenge_Request) baseReq; - - String sessionId = req.getSessionId(); - if (sessionId == null || sessionId.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SESSION_ID", - "Пустой sessionId" - ); - } - - ActiveSessionEntry session; - try { - session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId); - } catch (SQLException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка доступа к базе данных" - ); - } - - if (session == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_NOT_FOUND", - "Сессия не найдена" - ); - } - - byte[] buf = new byte[32]; - RANDOM.nextBytes(buf); - String nonce = Base64Ws.encode(buf); - - long now = System.currentTimeMillis(); - ctx.setSessionLoginNonce(nonce); - ctx.setSessionLoginSessionId(sessionId); - ctx.setSessionLoginNonceExpiresAtMs(now + NONCE_TTL_MS); - - Net_SessionChallenge_Response resp = new Net_SessionChallenge_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setNonce(nonce); - return resp; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.Base64Ws; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -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_SessionLogin_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; -import shine.geo.ClientInfoService; -import shine.geo.GeoLookupService; -import utils.crypto.Ed25519Util; - -import java.nio.charset.StandardCharsets; -import java.sql.SQLException; - -/** - * SessionLogin (v2) — шаг 2 входа в существующую сессию (по sessionKey). - * - * Логика авторизации (v2): - * - SessionChallenge(sessionId) выдаёт nonce (одноразовый, TTL). - * - SessionLogin проверяет подпись sessionKey над строкой: - * SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} - * - sessionPubKey берём из БД: active_sessions.session_key (base64 32 bytes). - * - * При успехе: - * - ctx становится AUTH_STATUS_USER - * - обновляем метаданные сессии (lastAuth + clientIp + clientInfo + lang) - * - возвращаем storagePwd - */ -public class Net_SessionLogin_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_SessionLogin_Handler.class); - - private static final long ALLOWED_SKEW_MS = 30_000L; - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_SessionLogin_Request req = (Net_SessionLogin_Request) baseReq; - - String sessionId = req.getSessionId(); - if (sessionId == null || sessionId.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SESSION_ID", - "Пустой sessionId" - ); - } - - // проверка челленджа - if (ctx.getSessionLoginNonce() == null - || ctx.getSessionLoginSessionId() == null - || System.currentTimeMillis() > ctx.getSessionLoginNonceExpiresAtMs()) { - - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_CHALLENGE", - "Нет активного SessionChallenge или nonce истёк" - ); - } - - if (!sessionId.equals(ctx.getSessionLoginSessionId())) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "SESSION_ID_MISMATCH", - "nonce был выдан для другого sessionId" - ); - } - - long timeMs = req.getTimeMs(); - long nowMs = System.currentTimeMillis(); - if (Math.abs(nowMs - timeMs) > ALLOWED_SKEW_MS) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "TIME_SKEW", - "Время клиента отличается от сервера более чем на 30 секунд" - ); - } - - String signatureB64 = req.getSignatureB64(); - if (signatureB64 == null || signatureB64.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SIGNATURE", - "Пустая подпись" - ); - } - - ActiveSessionEntry session; - try { - session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId); - } catch (SQLException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка доступа к базе данных" - ); - } - - if (session == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_NOT_FOUND", - "Сессия не найдена" - ); - } - - String sessionPubKeyB64 = session.getSessionKey(); // это pubKey (Base64(32)) - if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "NO_SESSION_KEY", - "В сессии не задан session_key" - ); - } - - String nonce = ctx.getSessionLoginNonce(); - - boolean sigOk; - try { - sigOk = verifySessionLoginSignature(sessionPubKeyB64, sessionId, timeMs, nonce, signatureB64); - } catch (IllegalArgumentException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "Некорректный Base64 для ключа/подписи" - ); - } - - if (!sigOk) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "BAD_SIGNATURE", - "Подпись не прошла проверку" - ); - } - - // сжигаем nonce - ctx.setSessionLoginNonce(null); - ctx.setSessionLoginSessionId(null); - ctx.setSessionLoginNonceExpiresAtMs(0); - - // подтягиваем пользователя - SolanaUserEntry user; - try { - user = SolanaUsersDAO.getInstance().getByLogin(session.getLogin()); - } catch (SQLException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR_USER_LOOKUP", - "Ошибка доступа к базе данных при получении пользователя" - ); - } - - if (user == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "USER_NOT_FOUND_FOR_SESSION", - "Пользователь для данной сессии не найден" - ); - } - - // обновление метаданных - String clientInfoFromClient = req.getClientInfo(); - if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) { - clientInfoFromClient = clientInfoFromClient.substring(0, 50); - } - - String clientIp = null; - String clientInfoFromRequest = null; - String userLanguage = null; - - if (ctx.getWsSession() != null) { - clientIp = ClientInfoService.extractClientIp(ctx.getWsSession()); - clientInfoFromRequest = ClientInfoService.buildClientInfoString(ctx.getWsSession()); - userLanguage = ClientInfoService.extractPreferredLanguageTag(ctx.getWsSession()); - - if (clientIp != null && !clientIp.isBlank()) { - try { - GeoLookupService.resolveCountryCityOrIpWithCache(clientIp); - } catch (Exception e) { - log.debug("Geo lookup failed for ip={}", clientIp, e); - } - } - } - - long now = System.currentTimeMillis(); - try { - ActiveSessionsDAO.getInstance().updateOnRefresh( - sessionId, - now, - clientIp, - clientInfoFromClient, - clientInfoFromRequest, - userLanguage - ); - } catch (SQLException e) { - log.error("Ошибка БД при updateOnRefresh sessionId={}", sessionId, e); - } - - session.setLastAuthirificatedAtMs(now); - session.setClientIp(clientIp); - session.setClientInfoFromClient(clientInfoFromClient); - session.setClientInfoFromRequest(clientInfoFromRequest); - session.setUserLanguage(userLanguage); - - // ctx - ctx.setActiveSession(session); - ctx.setSolanaUser(user); - ctx.setSessionId(sessionId); - ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); - - ActiveConnectionsRegistry.getInstance().register(ctx); - - // ответ - Net_SessionLogin_Response resp = new Net_SessionLogin_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setStoragePwd(session.getStoragePwd()); - return resp; - } - - private static boolean verifySessionLoginSignature( - String sessionPubKeyB64, - String sessionId, - long timeMs, - String nonce, - String signatureB64 - ) throws IllegalArgumentException { - - // pubKey: Base64(32). (Ed25519Util.keyFromBase64 должен использовать стандартный Base64) - byte[] publicKey32 = Ed25519Util.keyFromBase64(sessionPubKeyB64); - - // signature: Base64(64) через единую утилиту WS-протокола - byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); - - String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce; - byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); - - return Ed25519Util.verify(preimage, signature64, publicKey32); - } -} -package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -public final class Net_AddBlock_Request extends Net_Request { - - private String blockchainName; // обязателен - private int blockNumber; // обязателен - private String prevBlockHash; // HEX(64) или "" для нулевого - private String blockBytesB64; // байты FULL-блока (raw+sig+hash) в Base64 - - public String getBlockchainName() { return blockchainName; } - public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } - - public int getBlockNumber() { return blockNumber; } - public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; } - - public String getPrevBlockHash() { return prevBlockHash; } - public void setPrevBlockHash(String prevBlockHash) { this.prevBlockHash = prevBlockHash; } - - public String getBlockBytesB64() { return blockBytesB64; } - public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; } -} -package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ: - * - reasonCode (null если ok) - * - serverLastGlobalNumber / serverLastGlobalHash - */ -public final class Net_AddBlock_Response extends Net_Response { - - /** null если ok, иначе строка причины (bad_block_base64, user_not_found, и т.п.) */ - private String reasonCode; - - /** что сервер считает последним по глобальной цепочке */ - private int serverLastGlobalNumber; - private String serverLastGlobalHash; - - public String getReasonCode() { return reasonCode; } - public void setReasonCode(String reasonCode) { this.reasonCode = reasonCode; } - - public int getServerLastGlobalNumber() { return serverLastGlobalNumber; } - public void setServerLastGlobalNumber(int v) { this.serverLastGlobalNumber = v; } - - public String getServerLastGlobalHash() { return serverLastGlobalHash; } - public void setServerLastGlobalHash(String v) { this.serverLastGlobalHash = v; } -} -package server.logic.ws_protocol.JSON.handlers.blockchain; - -import blockchain.BchBlockEntry; -import blockchain.BchCryptoVerifier; -import blockchain.MsgSubType; -import blockchain.body.BodyHasLine; -import blockchain.body.BodyHasTarget; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.Base64Ws; -import server.logic.ws_protocol.JSON.ConnectionContext; -import server.logic.ws_protocol.JSON.entyties.Net_Exception_Response; -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.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks; -import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter; -import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; -import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.BlocksDAO; -import shine.db.entities.BlockchainStateEntry; -import shine.db.entities.BlockEntry; -import utils.blockchain.BlockchainNameUtil; - -import java.util.Arrays; -import java.util.concurrent.locks.ReentrantLock; - -/** - * Net_AddBlock_Handler — единый хэндлер добавления блока (JSON). - * - * Изменение (v3): - * - ВСЕ ошибки теперь возвращаются в стандартном формате Net_Exception_Response: - * status != 200, payload: { code, message, serverLastGlobalNumber, serverLastGlobalHash } - * - Успех — как и раньше Net_AddBlock_Response (status=200). - */ -public final class Net_AddBlock_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_AddBlock_Handler.class); - - private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); - private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); - - private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) { - - Net_AddBlock_Request req = (Net_AddBlock_Request) baseReq; - - String blockchainName = req.getBlockchainName(); - ReentrantLock lock = BlockchainLocks.lockFor(blockchainName); - lock.lock(); - try { - AddBlockResult r = addBlock( - blockchainName, - req.getBlockNumber(), // старое поле, пока оставляем - req.getPrevBlockHash(), // старое поле, пока оставляем - req.getBlockBytesB64() - ); - - // ✅ УСПЕХ: как раньше - if (r.isOk()) { - Net_AddBlock_Response resp = new Net_AddBlock_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - resp.setReasonCode(null); - resp.setServerLastGlobalNumber(r.serverLastBlockNumber); - resp.setServerLastGlobalHash(r.serverLastBlockHashHex); - - return resp; - } - - // ✅ ОШИБКА: стандартный формат (code + message) + доп.поля для ресинка - return error(req, r.httpStatus, r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex); - - } finally { - lock.unlock(); - } - } - - private Net_Response error(Net_AddBlock_Request req, - int status, - String reasonCode, - int serverLastNum, - String serverLastHashHex) { - - AddBlockExceptionResponse resp = new AddBlockExceptionResponse(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(status); - - // code — машинный - resp.setCode(reasonCode != null ? reasonCode : "add_block_error"); - // message — человеческий (можешь улучшать тексты как угодно) - resp.setMessage(humanMessage(reasonCode)); - - // полезно клиенту для ресинка - resp.setServerLastGlobalNumber(serverLastNum); - resp.setServerLastGlobalHash(serverLastHashHex); - - return resp; - } - - private static String humanMessage(String code) { - if (code == null) return "Ошибка добавления блока"; - - return switch (code) { - case "empty_blockchain_name" -> "Пустое имя блокчейна"; - case "bad_blockchain_name" -> "Некорректное имя блокчейна"; - case "db_error" -> "Ошибка базы данных"; - case "blockchain_state_not_found" -> "Состояние блокчейна не найдено"; - case "state_last_hash_invalid" -> "Повреждено состояние блокчейна: неверный last_block_hash"; - case "bad_block_base64" -> "Некорректный base64 блока"; - case "limit_exceeded" -> "Превышен лимит размера блокчейна"; - case "limit_check_failed" -> "Ошибка проверки лимита размера"; - case "bad_block_format" -> "Некорректный формат блока"; - case "bad_block_body" -> "Некорректное тело блока"; - case "bad_block_number" -> "Некорректный номер блока"; - case "req_global_mismatch" -> "Номер блока в запросе не совпадает с номером в блоке"; - case "bad_prev_hash" -> "Некорректный prevHash (цепочка не совпадает)"; - case "bad_blockchain_key_len" -> "Некорректный ключ блокчейна в состоянии (ожидалось 32 байта)"; - case "signature_verify_failed" -> "Ошибка проверки подписи блока"; - case "bad_signature" -> "Некорректная подпись блока"; - case "prev_line_block_not_found" -> "Не найден блок prevLineNumber для проверки линии"; - case "bad_prev_line_hash" -> "Некорректный prevLineHash"; - case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine"; - case "internal_error" -> "Внутренняя ошибка сервера при записи блока"; - default -> "Ошибка: " + code; - }; - } - - private AddBlockResult addBlock( - String blockchainName, - int globalNumberFromReq, - String prevGlobalHashHexFromReq, - String blockBytesB64 - ) { - if (blockchainName == null || blockchainName.isBlank()) { - log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "empty_blockchain_name", 0, ""); - } - - String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName); - if (login == null || login.isBlank()) { - log.warn("AddBlock: плохой blockchainName='{}' => login не получился (reqGlobalNumber={})", - blockchainName, globalNumberFromReq); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_name", 0, ""); - } - - // 1) state обязателен - final BlockchainStateEntry st; - try { - st = stateDAO.getByBlockchainName(blockchainName); - } catch (Exception e) { - log.error("AddBlock: ошибка БД при чтении blockchain_state (login={}, blockchainName={}, reqGlobalNumber={})", - login, blockchainName, globalNumberFromReq, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "db_error", 0, ""); - } - - if (st == null) { - log.warn("AddBlock: blockchain_state_not_found (login={}, blockchainName={}, reqGlobalNumber={})", - login, blockchainName, globalNumberFromReq); - return new AddBlockResult(WireCodes.Status.NOT_FOUND, "blockchain_state_not_found", -1, ""); - } - - final int serverLastNum = st.getLastBlockNumber(); - - final byte[] serverLastHash32; - try { - serverLastHash32 = (serverLastNum < 0) - ? new byte[32] - : require32OrThrow(st.getLastBlockHash(), "state.last_block_hash is null/invalid"); - } catch (Exception e) { - // ✅ Раньше тут мог вылететь неожиданный 500 через внешний try/catch. - log.error("AddBlock: state_last_hash_invalid (login={}, blockchainName={}, serverLastNum={})", - login, blockchainName, serverLastNum, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "state_last_hash_invalid", serverLastNum, ""); - } - - final String serverLastHashHex = toHex(serverLastHash32); - - // 2) decode block - final byte[] blockBytes; - try { - blockBytes = decodeBase64(blockBytesB64); - } catch (Exception e) { - log.warn("AddBlock: некорректный base64 блока (login={}, blockchainName={}, reqGlobalNumber={})", - login, blockchainName, globalNumberFromReq, e); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_base64", serverLastNum, serverLastHashHex); - } - - // 3) лимит (оставляем как было) - try { - long oldSize = st.getFileSizeBytes(); - long limit = st.getSizeLimit(); - long newSize = safeAdd(oldSize, blockBytes.length); - - if (limit > 0 && newSize > limit) { - log.warn("AddBlock: limit_exceeded (login={}, blockchainName={}, oldSize={}, addLen={}, newSize={}, limit={})", - login, blockchainName, oldSize, blockBytes.length, newSize, limit); - return new AddBlockResult(413, "limit_exceeded", serverLastNum, serverLastHashHex); - } - } catch (Exception e) { - log.error("AddBlock: limit_check_failed (login={}, blockchainName={})", login, blockchainName, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "limit_check_failed", serverLastNum, serverLastHashHex); - } - - // 4) parse block - final BchBlockEntry block; - try { - block = new BchBlockEntry(blockBytes); - } catch (Exception e) { - log.warn("AddBlock: не удалось распарсить BchBlockEntry (login={}, blockchainName={}, bytesLen={})", - login, blockchainName, blockBytes.length, e); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_format", serverLastNum, serverLastHashHex); - } - - // body.check() - try { - block.body.check(); - } catch (Exception e) { - log.warn("AddBlock: body.check() не прошёл (login={}, blockchainName={}, blockNumber={}, type={}, ver={})", - login, blockchainName, block.blockNumber, (block.type & 0xFFFF), (block.version & 0xFFFF), e); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex); - } - - // 4.2) запрет дырок: blockNumber строго last+1 - int expectedBlockNumber = serverLastNum + 1; - if (block.blockNumber != expectedBlockNumber) { - log.warn("AddBlock: bad_block_number (login={}, blockchainName={}, пришёл={}, ожидали={}, serverLastNum={})", - login, blockchainName, block.blockNumber, expectedBlockNumber, serverLastNum); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_number", serverLastNum, serverLastHashHex); - } - - // (временная совместимость) req.globalNumber должен совпасть с block.blockNumber - if (globalNumberFromReq != block.blockNumber) { - log.warn("AddBlock: req_global_mismatch (login={}, blockchainName={}, reqGlobal={}, blockNumber={})", - login, blockchainName, globalNumberFromReq, block.blockNumber); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "req_global_mismatch", serverLastNum, serverLastHashHex); - } - - // 4.3) проверка цепочки по prevHash32 - if (!Arrays.equals(block.prevHash32, serverLastHash32)) { - log.warn("AddBlock: bad_prev_hash (login={}, blockchainName={}, blockNumber={}, clientPrev={}, serverPrev={})", - login, blockchainName, block.blockNumber, toHex(block.prevHash32), serverLastHashHex); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_prev_hash", serverLastNum, serverLastHashHex); - } - - // 5) pubKey - final byte[] pubKey32 = st.getBlockchainKeyBytes(); - if (pubKey32 == null || pubKey32.length != 32) { - log.warn("AddBlock: bad_blockchain_key_len (login={}, blockchainName={}, blockNumber={}, keyLen={})", - login, blockchainName, block.blockNumber, (pubKey32 == null ? -1 : pubKey32.length)); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex); - } - - // 6) подпись по hash32(preimage) - boolean sigOk; - try { - sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32); - } catch (Exception e) { - log.warn("AddBlock: signature_verify_failed (login={}, blockchainName={}, blockNumber={})", - login, blockchainName, block.blockNumber, e); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "signature_verify_failed", serverLastNum, serverLastHashHex); - } - - if (!sigOk) { - log.warn("AddBlock: bad_signature (login={}, blockchainName={}, blockNumber={})", - login, blockchainName, block.blockNumber); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_signature", serverLastNum, serverLastHashHex); - } - - // 7) line columns (only for BodyHasLine) - Integer lineCode = null; - Integer prevLineNumber = null; - byte[] prevLineHash32 = null; - Integer thisLineNumber = null; - - if (block.body instanceof BodyHasLine bl) { - lineCode = bl.lineCode(); - prevLineNumber = bl.prevLineBlockGlobalNumber(); - prevLineHash32 = bl.prevLineBlockHash32(); - thisLineNumber = bl.lineSeq(); - - // Нормализация: -1 не пишем в БД (для совместимости со старым TextBody) - if (prevLineNumber != null && prevLineNumber == -1) { - prevLineNumber = null; - prevLineHash32 = null; - thisLineNumber = null; - } - - // Если prevLineNumber задан — проверяем его хэш - if (prevLineNumber != null) { - try { - byte[] dbPrevHash = blocksDAO.getHashByNumber(blockchainName, prevLineNumber); - if (dbPrevHash == null) { - log.warn("AddBlock: prev_line_block_not_found (login={}, blockchainName={}, blockNumber={}, prevLineNumber={})", - login, blockchainName, block.blockNumber, prevLineNumber); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "prev_line_block_not_found", serverLastNum, serverLastHashHex); - } - if (!Arrays.equals(dbPrevHash, require32OrThrow(prevLineHash32, "prevLineHash32 invalid"))) { - log.warn("AddBlock: bad_prev_line_hash (login={}, blockchainName={}, blockNumber={}, prevLineNumber={})", - login, blockchainName, block.blockNumber, prevLineNumber); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_prev_line_hash", serverLastNum, serverLastHashHex); - } - } catch (Exception e) { - log.error("AddBlock: db_error_prev_line_check (login={}, blockchainName={}, blockNumber={})", - login, blockchainName, block.blockNumber, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "db_error_prev_line_check", serverLastNum, serverLastHashHex); - } - } - } - - // 8) сформировать запись и записать (DB + state + файл) - try { - BlockEntry be = new BlockEntry(); - be.setLogin(login); - be.setBchName(blockchainName); - - be.setBlockNumber(block.blockNumber); - be.setMsgType(block.type & 0xFFFF); - be.setMsgSubType(block.subType & 0xFFFF); - - be.setBlockBytes(block.toBytes()); - be.setBlockHash(block.getHash32()); - be.setBlockSignature(block.getSignature64()); - - // line columns (optional) - be.setLineCode(lineCode); - be.setPrevLineNumber(prevLineNumber); - be.setPrevLineHash(prevLineHash32); - be.setThisLineNumber(thisLineNumber); - - // target columns (optional) - if (block.body instanceof BodyHasTarget t) { - be.setToLogin(t.toLogin()); - be.setToBchName(t.toBchName()); - be.setToBlockNumber(t.toBlockGlobalNumber()); - be.setToBlockHash(t.toBlockHashBytes()); - } - - // edit helper (optional): если TEXT_EDIT_* — это "редактирование блока цели" - int type = block.type & 0xFFFF; - int sub = block.subType & 0xFFFF; - - if (type == 1 - && (sub == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || sub == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) - && be.getToBlockNumber() != null) { - be.setEditedByBlockNumber(be.getToBlockNumber()); - } - - dbWriter.appendBlockAndState(blockchainName, block, st, be); - - } catch (Exception e) { - log.error("AddBlock: внутренняя ошибка при записи блока (login={}, blockchainName={}, blockNumber={})", - login, blockchainName, block.blockNumber, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex); - } - - String newHashHex = toHex(block.getHash32()); - - log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}", - login, blockchainName, block.blockNumber, newHashHex); - - return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex); - } - - /* ===================================================================== */ - /* ====================== Helpers ====================================== */ - /* ===================================================================== */ - - private static byte[] decodeBase64(String b64) { - if (b64 == null) throw new IllegalArgumentException("blockBytesB64 == null"); - return Base64Ws.decode(b64); - } - - private static long safeAdd(long a, long b) { - long r = a + b; - if (((a ^ r) & (b ^ r)) < 0) throw new ArithmeticException("long overflow"); - return r; - } - - private static byte[] require32OrThrow(byte[] b, String msg) { - if (b == null || b.length != 32) throw new IllegalArgumentException(msg); - return b; - } - - private static String toHex(byte[] bytes) { - if (bytes == null) return "null"; - char[] HEX = "0123456789abcdef".toCharArray(); - char[] out = new char[bytes.length * 2]; - for (int i = 0; i < bytes.length; i++) { - int v = bytes[i] & 0xFF; - out[i * 2] = HEX[v >>> 4]; - out[i * 2 + 1] = HEX[v & 0x0F]; - } - return new String(out); - } - - /** - * Спец-ответ ошибки AddBlock: стандартный code/message + поля для ресинка. - * В wire-формате это окажется внутри payload. - */ - public static final class AddBlockExceptionResponse extends Net_Exception_Response { - private Integer serverLastGlobalNumber; - private String serverLastGlobalHash; - - public Integer getServerLastGlobalNumber() { - return serverLastGlobalNumber; - } - - public void setServerLastGlobalNumber(Integer serverLastGlobalNumber) { - this.serverLastGlobalNumber = serverLastGlobalNumber; - } - - public String getServerLastGlobalHash() { - return serverLastGlobalHash; - } - - public void setServerLastGlobalHash(String serverLastGlobalHash) { - this.serverLastGlobalHash = serverLastGlobalHash; - } - } - - private static final class AddBlockResult { - final int httpStatus; - final String reasonCode; - final int serverLastBlockNumber; - final String serverLastBlockHashHex; - - AddBlockResult(int httpStatus, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) { - this.httpStatus = httpStatus; - this.reasonCode = reasonCode; - this.serverLastBlockNumber = serverLastBlockNumber; - this.serverLastBlockHashHex = serverLastBlockHashHex; - } - - boolean isOk() { return httpStatus == WireCodes.Status.OK; } - } -} - -package server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils; - -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.ReentrantLock; - -public final class BlockchainLocks { - private static final ConcurrentHashMap MAP = new ConcurrentHashMap<>(); - - private BlockchainLocks() {} - - public static ReentrantLock lockFor(String blockchainName) { - return MAP.computeIfAbsent(blockchainName, id -> new ReentrantLock(true)); // fair=true - } -} -package server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils; - -import blockchain.BchBlockEntry; -import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.BlocksDAO; -import shine.db.entities.BlockchainStateEntry; -import shine.db.entities.BlockEntry; -import utils.files.FileStoreUtil; - -import java.sql.Connection; -import java.sql.SQLException; - -/** - * BlockchainWriter — запись блока в DB + обновление state + запись в файл. - * - * ВАЖНО: - * - Это минимальный рабочий вариант под новый формат. - * - Если у тебя уже есть "атомарность" сложнее (tmp_bch + commit/recovery) — можно усилить потом. - */ -public final class BlockchainWriter { - - private final BlocksDAO blocksDAO; - private final BlockchainStateDAO stateDAO; - private final FileStoreUtil fs = FileStoreUtil.getInstance(); - - public BlockchainWriter(BlocksDAO blocksDAO, BlockchainStateDAO stateDAO) { - this.blocksDAO = blocksDAO; - this.stateDAO = stateDAO; - } - - public void appendBlockAndState(String blockchainName, - BchBlockEntry block, - BlockchainStateEntry st, - BlockEntry be) throws SQLException { - - long nowMs = System.currentTimeMillis(); - - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) { - c.setAutoCommit(false); - try { - // 1) insert block - blocksDAO.insert(c, be); - - // 2) update state - st.setLastBlockNumber(block.blockNumber); - st.setLastBlockHash(block.getHash32()); - st.setFileSizeBytes(st.getFileSizeBytes() + block.toBytes().length); - st.setUpdatedAtMs(nowMs); - - stateDAO.upsert(c, st); - - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - if (e instanceof SQLException se) throw se; - throw new SQLException("appendBlockAndState failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } - - // 3) append to file (минимально: просто дописать) - // Если у тебя уже есть логика tmp_bch+atomicReplace — можно заменить тут. - String fileName = fs.buildBlockchainFileName(blockchainName); - fs.addDataToFile(fileName, block.toBytes()); - } -} -package server.logic.ws_protocol.JSON.handlers.connections.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос GetFriendsLists — получить два списка "друзей" по connections_state. - * - * { - * "op": "GetFriendsLists", - * "requestId": "req-100", - * "payload": { - * "login": "anya" - * } - * } - * - * Возвращает: - * - out_friends: кому login поставил FRIEND - * - in_friends: кто поставил FRIEND этому login - * - * ПРО ДОСТУП (на будущее): - * Сейчас (MVP) без ограничений. Позже можно ограничить видимость связей. - */ -public class Net_GetFriendsLists_Request extends Net_Request { - - private String login; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } -} -package server.logic.ws_protocol.JSON.handlers.connections.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.ArrayList; -import java.util.List; - -/** - * Ответ GetFriendsLists. - * - * { - * "op": "GetFriendsLists", - * "requestId": "req-100", - * "status": 200, - * "payload": { - * "login": "Anya", // канонический регистр из БД - * "out_friends": ["Bob", "Kate"], // кому login поставил FRIEND - * "in_friends": ["Alex", "Kate"] // кто поставил FRIEND login - * } - * } - */ -public class Net_GetFriendsLists_Response extends Net_Response { - - private String login; - - private List out_friends = new ArrayList<>(); - private List in_friends = new ArrayList<>(); - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public List getOut_friends() { return out_friends; } - public void setOut_friends(List out_friends) { this.out_friends = out_friends; } - - public List getIn_friends() { return in_friends; } - public void setIn_friends(List in_friends) { this.in_friends = in_friends; } -} -package server.logic.ws_protocol.JSON.handlers.connections; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.connections.entyties.Net_GetFriendsLists_Request; -import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.MsgSubType; -import shine.db.SqliteDbController; -import shine.db.dao.ConnectionsStateDAO; - -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.util.List; - -/** - * GetFriendsLists — получить 2 списка: - * - out_friends: кому login поставил FRIEND - * - in_friends: кто поставил FRIEND этому login - * - * ВАЖНО: - * - login в запросе может быть любым регистром - * - в ответе возвращаем канонический регистр (как в solana_users.login) - * - * ПРИМЕЧАНИЕ: - * Таблица пользователей тут названа "solana_users". Если у тебя иначе — поменяй SQL. - */ -public class Net_GetFriendsLists_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_GetFriendsLists_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_GetFriendsLists_Request req = (Net_GetFriendsLists_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login" - ); - } - - final String loginAnyCase = req.getLogin().trim(); - - try { - SqliteDbController db = SqliteDbController.getInstance(); - ConnectionsStateDAO dao = ConnectionsStateDAO.getInstance(); - - try (Connection c = db.getConnection()) { - - // 1) Канонизируем login через solana_users (NOCASE) - String canonicalLogin = findCanonicalLogin(c, loginAnyCase); - if (canonicalLogin == null) { - return NetExceptionResponseFactory.error( - req, - 404, - "USER_NOT_FOUND", - "Пользователь не найден" - ); - } - - int relType = (int) MsgSubType.CONNECTION_FRIEND; - - // 2) Два списка (логины канонические) - List outFriends = dao.listOutgoingByRelTypeCanonical(c, canonicalLogin, relType); - List inFriends = dao.listIncomingByRelTypeCanonical(c, canonicalLogin, relType); - - Net_GetFriendsLists_Response resp = new Net_GetFriendsLists_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - resp.setLogin(canonicalLogin); - resp.setOut_friends(outFriends); - resp.setIn_friends(inFriends); - - return resp; - } - - } catch (Exception e) { - log.error("❌ Internal error GetFriendsLists", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } - - private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception { - String sql = """ - SELECT login - FROM solana_users - WHERE login = ? COLLATE NOCASE - LIMIT 1 - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, loginAnyCase); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return rs.getString("login"); - } - } - } -} -package server.logic.ws_protocol.JSON.handlers; - -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; - -/** - * Общий интерфейс для всех JSON-хэндлеров. - */ -public interface JsonMessageHandler { - - /** - * Обработать запрос и вернуть ответ. - * - * @param request распарсенный запрос - * @param ctx контекст текущего WebSocket-соединения - */ - Net_Response handle(Net_Request request, ConnectionContext ctx) throws Exception; -} - -package server.logic.ws_protocol.JSON.handlers.system.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Ping: - * { - * "op": "Ping", - * "requestId": "req-1", - * "payload": { "ts": 1700000000000 } - * } - * - * Сервер ничего не проверяет, поле ts можно слать любое. - */ -public class Net_Ping_Request extends Net_Request { - - private long ts; - - public long getTs() { return ts; } - public void setTs(long ts) { this.ts = ts; } -} -package server.logic.ws_protocol.JSON.handlers.system.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Pong-ответ: - * { - * "op": "Ping", - * "requestId": "req-1", - * "status": 200, - * "payload": { "ts": 1700000000123 } - * } - */ -public class Net_Ping_Response extends Net_Response { - - private long ts; - - public long getTs() { return ts; } - public void setTs(long ts) { this.ts = ts; } -} -package server.logic.ws_protocol.JSON.handlers.system; - -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.system.entyties.Net_Ping_Request; -import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Response; -import server.logic.ws_protocol.WireCodes; - -/** - * Ping — keep-alive. - * В ответ кладём только ts (текущее время сервера в мс). - */ -public class Net_Ping_Handler implements JsonMessageHandler { - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_Ping_Request req = (Net_Ping_Request) baseRequest; - - Net_Ping_Response resp = new Net_Ping_Response(); - resp.setOp(req.getOp()); // "Ping" - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - // ничего не проверяем, просто отдаём серверное время - resp.setTs(System.currentTimeMillis()); - - return resp; - } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос AddUser — временная/тестовая регистрация локального пользователя. - * - * Клиент отправляет: - * - * { - * "op": "AddUser", - * "requestId": "test-add-1", - * "payload": { - * "login": "anya", - * "blockchainName": "anya-001", - * "solanaKey": "base64-ed25519-public-key-login", - * "blockchainKey": "base64-ed25519-public-key-blockchain", - * "deviceKey": "base64-ed25519-public-key-device", - * "bchLimit": 1000000 - * } - * } - * - * Все поля лежат внутри payload. - */ -public class Net_AddUser_Request extends Net_Request { - - private String login; - private String blockchainName; - - /** Ключ пользователя Solana (публичный ключ логина) */ - private String solanaKey; - - /** Ключ блокчейна (публичный ключ блокчейна) */ - private String blockchainKey; - - /** Ключ устройства (публичный ключ устройства) */ - private String deviceKey; - - private Integer bchLimit; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getBlockchainName() { return blockchainName; } - public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } - - public String getSolanaKey() { return solanaKey; } - public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; } - - public String getBlockchainKey() { return blockchainKey; } - public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } - - public String getDeviceKey() { return deviceKey; } - public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } - - public Integer getBchLimit() { return bchLimit; } - public void setBchLimit(Integer bchLimit) { this.bchLimit = bchLimit; } -} -// file: server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Response.java -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Успешный ответ на AddUser. - * - * Сейчас дополнительных полей нет — достаточно status=200. - * - * Пример: - * { - * "op": "AddUser", - * "requestId": "test-add-1", - * "status": 200, - * "payload": { } - * } - */ -public class Net_AddUser_Response extends Net_Response { - // При необходимости сюда можно добавить, например, флаг created/updated и т.п. -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос GetUser — проверка/получение пользователя по login. - * - * Клиент отправляет: - * - * { - * "op": "GetUser", - * "requestId": "u-1", - * "payload": { - * "login": "AnYa" - * } - * } - * - * Поиск по login выполняется без учёта регистра. - * В ответе возвращаем login/blockchainName с тем регистром, как в БД. - */ -public class Net_GetUser_Request extends Net_Request { - - private String login; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ GetUser. - * - * Всегда status=200. - * - * Пример (нет пользователя): - * { - * "op": "GetUser", - * "requestId": "u-1", - * "status": 200, - * "payload": { "exists": false } - * } - * - * Пример (есть пользователь): - * { - * "op": "GetUser", - * "requestId": "u-1", - * "status": 200, - * "payload": { - * "exists": true, - * "login": "Anya", - * "blockchainName": "anya-001", - * "solanaKey": "...", - * "blockchainKey": "...", - * "deviceKey": "..." - * } - * } - */ -public class Net_GetUser_Response extends Net_Response { - - private Boolean exists; - - private String login; - private String blockchainName; - private String solanaKey; - private String blockchainKey; - private String deviceKey; - - public Boolean getExists() { return exists; } - public void setExists(Boolean exists) { this.exists = exists; } - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getBlockchainName() { return blockchainName; } - public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } - - public String getSolanaKey() { return solanaKey; } - public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; } - - public String getBlockchainKey() { return blockchainKey; } - public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } - - public String getDeviceKey() { return deviceKey; } - public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос SearchUsers — поиск логинов по префиксу. - * - * Клиент отправляет: - * { - * "op": "SearchUsers", - * "requestId": "su-1", - * "payload": { "prefix": "any" } - * } - * - * Поиск по prefix выполняется без учёта регистра. - * В ответе возвращаем логины с тем регистром, как в БД. - */ -public class Net_SearchUsers_Request extends Net_Request { - - private String prefix; - - public String getPrefix() { return prefix; } - public void setPrefix(String prefix) { this.prefix = prefix; } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.ArrayList; -import java.util.List; - -/** - * Ответ SearchUsers. - * - * Всегда status=200. - * - * Пример: - * { - * "op": "SearchUsers", - * "requestId": "su-1", - * "status": 200, - * "payload": { - * "logins": ["Anya", "andrew", "Angel"] - * } - * } - */ -public class Net_SearchUsers_Response extends Net_Response { - - private List logins = new ArrayList<>(); - - public List getLogins() { return logins; } - public void setLogins(List logins) { this.logins = logins; } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.Base64Ws; -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.tempToTest.entyties.Net_AddUser_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.BlockchainStateEntry; -import shine.db.entities.SolanaUserEntry; -import utils.blockchain.BlockchainNameUtil; - -import java.sql.Connection; -import java.sql.SQLException; - -public class Net_AddUser_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_AddUser_Handler.class); - - /** TEST ONLY */ - private static final int TEST_BCH_LIMIT = 1_000_000; - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_AddUser_Request req = (Net_AddUser_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank() - || req.getBlockchainName() == null || req.getBlockchainName().isBlank() - || req.getSolanaKey() == null || req.getSolanaKey().isBlank() - || req.getBlockchainKey() == null || req.getBlockchainKey().isBlank() - || req.getDeviceKey() == null || req.getDeviceKey().isBlank()) { - - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login/blockchainName/solanaKey/blockchainKey/deviceKey" - ); - } - - // blockchainName должен быть вида: -NNN - if (!BlockchainNameUtil.isBlockchainNameMatchesLogin(req.getBlockchainName(), req.getLogin())) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BLOCKCHAIN_NAME", - "blockchainName должен быть вида -NNN (пример: anya-001)" - ); - } - - int limit = (req.getBchLimit() == null || req.getBchLimit() <= 0) - ? TEST_BCH_LIMIT - : req.getBchLimit(); - - try { - // базовая валидация форматов ключей: Base64(32 bytes) - byte[] solanaKey32; - byte[] blockchainKey32; - byte[] deviceKey32; - - try { - solanaKey32 = Base64Ws.decodeLen(req.getSolanaKey(), 32, "solanaKey"); - blockchainKey32 = Base64Ws.decodeLen(req.getBlockchainKey(), 32, "blockchainKey"); - deviceKey32 = Base64Ws.decodeLen(req.getDeviceKey(), 32, "deviceKey"); - } catch (IllegalArgumentException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_KEY_FORMAT", - e.getMessage() - ); - } - - // (переменные не используются дальше, но оставляем для ясности проверки длины) - if (solanaKey32.length != 32 || blockchainKey32.length != 32 || deviceKey32.length != 32) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_KEY_FORMAT", - "solanaKey/blockchainKey/deviceKey должны быть Base64(32 bytes)" - ); - } - - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); - BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); - - SqliteDbController db = SqliteDbController.getInstance(); - - try (Connection c = db.getConnection()) { - c.setAutoCommit(false); - - // 1. Проверяем, что пользователя нет (case-insensitive) - if (usersDAO.getByLogin(c, req.getLogin()) != null) { - return NetExceptionResponseFactory.error( - req, - 409, - "USER_ALREADY_EXISTS", - "Пользователь с таким login уже существует" - ); - } - - // 2. Проверяем, что blockchainName ещё нет (case-sensitive, как в БД) - if (usersDAO.existsByBlockchainName(c, req.getBlockchainName())) { - return NetExceptionResponseFactory.error( - req, - 409, - "BLOCKCHAIN_ALREADY_EXISTS", - "Пользователь с таким blockchainName уже существует" - ); - } - - // 3. На всякий случай оставляем старую проверку blockchain_state, - // потому что эта таблица нужна серверу (состояние цепочки/лимиты). - if (stateDAO.getByBlockchainName(c, req.getBlockchainName()) != null) { - return NetExceptionResponseFactory.error( - req, - 409, - "BLOCKCHAIN_STATE_ALREADY_EXISTS", - "blockchain_state уже существует" - ); - } - - // 4. Создаём пользователя (все поля теперь лежат в solana_users) - SolanaUserEntry user = new SolanaUserEntry(); - user.setLogin(req.getLogin()); - user.setBlockchainName(req.getBlockchainName()); - user.setSolanaKey(req.getSolanaKey()); - user.setBlockchainKey(req.getBlockchainKey()); - user.setDeviceKey(req.getDeviceKey()); - - usersDAO.insert(c, user); - - // 5. Создаём INITIAL blockchain_state (для работы сервера) - BlockchainStateEntry st = new BlockchainStateEntry(); - st.setBlockchainName(req.getBlockchainName()); - st.setLogin(req.getLogin()); - st.setBlockchainKey(req.getBlockchainKey()); // Base64(32) - st.setLastBlockNumber(-1); - st.setLastBlockHash(new byte[32]); - st.setFileSizeBytes(0); - st.setSizeLimit(limit); - st.setUpdatedAtMs(System.currentTimeMillis()); - - stateDAO.upsert(c, st); - - c.commit(); - } - - Net_AddUser_Response resp = new Net_AddUser_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - log.info("✅ AddUser ok: login={}, blockchainName={}, limit={}", - req.getLogin(), req.getBlockchainName(), limit); - - return resp; - - } catch (SQLException e) { - log.error("❌ DB error AddUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error AddUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.tempToTest.entyties.Net_GetUser_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; - -import java.sql.SQLException; - -public class Net_GetUser_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_GetUser_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_GetUser_Request req = (Net_GetUser_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank()) { - // тут логичнее BAD_REQUEST, но ты просил: "нет пользователя" тоже 200. - // Поэтому BAD_REQUEST оставляем только на реально пустой login. - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login" - ); - } - - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); - - try { - SolanaUserEntry u = usersDAO.getByLogin(req.getLogin()); - - Net_GetUser_Response resp = new Net_GetUser_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - if (u == null) { - resp.setExists(false); - log.info("ℹ️ GetUser: not found for login={}", req.getLogin()); - return resp; - } - - // ВАЖНО: - // - Поиск по login был case-insensitive, - // - а тут возвращаем login/blockchainName как в БД (с исходным регистром). - resp.setExists(true); - resp.setLogin(u.getLogin()); - resp.setBlockchainName(u.getBlockchainName()); - resp.setSolanaKey(u.getSolanaKey()); - resp.setBlockchainKey(u.getBlockchainKey()); - resp.setDeviceKey(u.getDeviceKey()); - - log.info("✅ GetUser: found login={}, blockchainName={}", u.getLogin(), u.getBlockchainName()); - return resp; - - } catch (SQLException e) { - log.error("❌ DB error GetUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error GetUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.tempToTest.entyties.Net_SearchUsers_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_SearchUsers_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; - -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.List; - -public class Net_SearchUsers_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_SearchUsers_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_SearchUsers_Request req = (Net_SearchUsers_Request) baseRequest; - - if (req.getPrefix() == null || req.getPrefix().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: prefix" - ); - } - - String prefix = req.getPrefix().trim(); - - try { - SolanaUsersDAO dao = SolanaUsersDAO.getInstance(); - List users = dao.searchByLoginPrefix(prefix); // case-insensitive + LIMIT 5 - - List logins = new ArrayList<>(); - for (SolanaUserEntry u : users) { - if (u != null && u.getLogin() != null) { - logins.add(u.getLogin()); // регистр как в БД - } - } - - Net_SearchUsers_Response resp = new Net_SearchUsers_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setLogins(logins); - - log.info("✅ SearchUsers ok: prefix='{}' -> {}", prefix, logins.size()); - return resp; - - } catch (SQLException e) { - log.error("❌ DB error SearchUsers", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error SearchUsers", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос GetUserParam — получить один параметр пользователя. - * - * { - * "op": "GetUserParam", - * "requestId": "req-1", - * "payload": { - * "login": "anya", - * "param": "feed:lastSeenGlobal" - * } - * } - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) этот запрос не ограничивает просмотр параметров, т.к. проект в тестовом режиме. - * Позже, вероятно, потребуется ограничить: кто и какие параметры может читать (сессия/права). - * Но для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_GetUserParam_Request extends Net_Request { - - private String login; - private String param; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ GetUserParam. - * - * Если найден: - * { - * "op": "GetUserParam", - * "requestId": "req-1", - * "status": 200, - * "payload": { - * "login": "anya", - * "param": "feed:lastSeenGlobal", - * "time_ms": 1736000000123, - * "value": "105", - * "device_key": "base64-32", - * "signature": "base64-64" - * } - * } - * - * Если не найден: - * status=404, payload пустой. - */ -public class Net_GetUserParam_Response extends Net_Response { - - private String login; - private String param; - private Long time_ms; - private String value; - private String device_key; - private String signature; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } - - public Long getTime_ms() { return time_ms; } - public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } - - public String getValue() { return value; } - public void setValue(String value) { this.value = value; } - - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос ListUserParams — получить все сохранённые параметры пользователя. - * - * { - * "op": "ListUserParams", - * "requestId": "req-2", - * "payload": { - * "login": "anya" - * } - * } - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) запрос не ограничивает просмотр параметров. - * В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры. - * Для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_ListUserParams_Request extends Net_Request { - - private String login; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.ArrayList; -import java.util.List; - -/** - * Ответ ListUserParams — список всех параметров пользователя. - * - * { - * "op": "ListUserParams", - * "requestId": "req-2", - * "status": 200, - * "payload": { - * "login": "anya", - * "params": [ - * { - * "login": "anya", - * "param": "feed:lastSeenGlobal", - * "time_ms": 1736000000123, - * "value": "105", - * "device_key": "base64-32", - * "signature": "base64-64" - * }, - * ... - * ] - * } - * } - */ -public class Net_ListUserParams_Response extends Net_Response { - - private String login; - private List params = new ArrayList<>(); - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public List getParams() { return params; } - public void setParams(List params) { this.params = params; } - - public static class Item { - private String login; - private String param; - private Long time_ms; - private String value; - private String device_key; - private String signature; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } - - public Long getTime_ms() { return time_ms; } - public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } - - public String getValue() { return value; } - public void setValue(String value) { this.value = value; } - - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос UpsertUserParam — добавить/обновить сохранённый параметр пользователя. - * - * Клиент отправляет: - * - * { - * "op": "UpsertUserParam", - * "requestId": "req-123", - * "payload": { - * "login": "anya", - * "param": "feed:lastSeenGlobal", - * "time_ms": 1736000000123, - * "value": "105", - * "device_key": "base64-ed25519-public-key-32", - * "signature": "base64-ed25519-signature-64" - * } - * } - * - * Подпись считается от UTF-8 строки: - * USER_PARAMETER_PREFIX + login + param + time_ms + value - */ -public class Net_UpsertUserParam_Request extends Net_Request { - - private String login; - private String param; - private Long time_ms; - private String value; - - private String device_key; - private String signature; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } - - public Long getTime_ms() { return time_ms; } - public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } - - public String getValue() { return value; } - public void setValue(String value) { this.value = value; } - - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на UpsertUserParam. - * - * Успех: - * { - * "op": "UpsertUserParam", - * "requestId": "req-123", - * "status": 200, - * "payload": { } - * } - */ -public class Net_UpsertUserParam_Response extends Net_Response { - // MVP: без payload. При желании позже можно добавить created/updated. -} -package server.logic.ws_protocol.JSON.handlers.userParams; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.userParams.entyties.Net_GetUserParam_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.UserParamsDAO; -import shine.db.entities.UserParamEntry; - -import java.sql.Connection; - -/** - * GetUserParam — получить один параметр пользователя. - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) запрос не ограничивает просмотр параметров. - * В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры. - * Для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_GetUserParam_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_GetUserParam_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_GetUserParam_Request req = (Net_GetUserParam_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank() - || req.getParam() == null || req.getParam().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login/param" - ); - } - - String login = req.getLogin().trim(); - String param = req.getParam().trim(); - - try { - SqliteDbController db = SqliteDbController.getInstance(); - UserParamsDAO dao = UserParamsDAO.getInstance(); - - try (Connection c = db.getConnection()) { - UserParamEntry e = dao.getByLoginAndParam(c, login, param); - - if (e == null) { - Net_GetUserParam_Response resp = new Net_GetUserParam_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(404); - return resp; - } - - Net_GetUserParam_Response resp = new Net_GetUserParam_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - resp.setLogin(e.getLogin()); - resp.setParam(e.getParam()); - resp.setTime_ms(e.getTimeMs()); - resp.setValue(e.getValue()); - resp.setDevice_key(e.getDeviceKey()); - resp.setSignature(e.getSignature()); - - return resp; - } - - } catch (Exception e) { - log.error("❌ Internal error GetUserParam", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.userParams.entyties.Net_ListUserParams_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.UserParamsDAO; -import shine.db.entities.UserParamEntry; - -import java.sql.Connection; -import java.util.ArrayList; -import java.util.List; - -/** - * ListUserParams — получить все параметры пользователя. - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) запрос не ограничивает просмотр параметров. - * В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры. - * Для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_ListUserParams_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_ListUserParams_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_ListUserParams_Request req = (Net_ListUserParams_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login" - ); - } - - String login = req.getLogin().trim(); - - try { - SqliteDbController db = SqliteDbController.getInstance(); - UserParamsDAO dao = UserParamsDAO.getInstance(); - - List entries; - try (Connection c = db.getConnection()) { - entries = dao.getByLogin(c, login); - } - - Net_ListUserParams_Response resp = new Net_ListUserParams_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - resp.setLogin(login); - - List items = new ArrayList<>(); - for (UserParamEntry e : entries) { - Net_ListUserParams_Response.Item it = new Net_ListUserParams_Response.Item(); - it.setLogin(e.getLogin()); - it.setParam(e.getParam()); - it.setTime_ms(e.getTimeMs()); - it.setValue(e.getValue()); - it.setDevice_key(e.getDeviceKey()); - it.setSignature(e.getSignature()); - items.add(it); - } - resp.setParams(items); - - return resp; - - } catch (Exception e) { - log.error("❌ Internal error ListUserParams", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.Base64Ws; -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.userParams.entyties.Net_UpsertUserParam_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.SolanaUsersDAO; -import shine.db.dao.UserParamsDAO; -import shine.db.entities.SolanaUserEntry; -import shine.db.entities.UserParamEntry; -import utils.config.ShineSignatureConstants; -import utils.crypto.Ed25519Util; - -import java.nio.charset.StandardCharsets; -import java.sql.Connection; -import java.sql.SQLException; - -/** - * Net_UpsertUserParam_Handler - * - * Делает (MVP, без "сессий"): - * 1) Проверка входных полей. - * 2) Проверка подписи Ed25519 по device_key. - * 3) Проверка, что пользователь существует и что device_key принадлежит этому login. - * 4) Атомарная запись в БД "только если time_ms новее" (UPSERT + WHERE). - * - * ВАЖНО: - * - НИКАКИХ ручных транзакций / BEGIN здесь нет. - * - autoCommit=true, каждый statement завершённый сам по себе. - * - Гонки не страшны: если за время проверок кто-то записал более новый time_ms, - * наш финальный UPSERT просто вернёт 0 обновлённых строк. - */ -public class Net_UpsertUserParam_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_UpsertUserParam_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_UpsertUserParam_Request req = (Net_UpsertUserParam_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank() - || req.getParam() == null || req.getParam().isBlank() - || req.getTime_ms() == null || req.getTime_ms() <= 0 - || req.getValue() == null - || req.getDevice_key() == null || req.getDevice_key().isBlank() - || req.getSignature() == null || req.getSignature().isBlank()) { - - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login/param/time_ms/value/device_key/signature" - ); - } - - final String login = req.getLogin().trim(); - final String param = req.getParam().trim(); - final long timeMs = req.getTime_ms(); - final String value = req.getValue(); - final String deviceKeyB64 = req.getDevice_key().trim(); - final String signatureB64 = req.getSignature().trim(); - - try { - // ---------------- Base64 decode ---------------- - byte[] pubKey32; - byte[] sig64; - try { - pubKey32 = Base64Ws.decodeLen(deviceKeyB64, 32, "device_key"); - sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature"); - } catch (IllegalArgumentException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "device_key/signature должны быть Base64" - ); - } - - // ---------------- Signature verify ---------------- - String signText = ShineSignatureConstants.USER_PARAMETER_PREFIX - + login - + param - + timeMs - + value; - - byte[] signBytes = signText.getBytes(StandardCharsets.UTF_8); - - boolean sigOk = Ed25519Util.verify(signBytes, sig64, pubKey32); - if (!sigOk) { - return NetExceptionResponseFactory.error( - req, - 403, - "SIGNATURE_INVALID", - "Подпись не прошла проверку" - ); - } - - // ---------------- DB checks + upsert ---------------- - SqliteDbController db = SqliteDbController.getInstance(); - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); - UserParamsDAO paramsDAO = UserParamsDAO.getInstance(); - - try (Connection c = db.getConnection()) { - // 1) user exists - SolanaUserEntry user = usersDAO.getByLogin(c, login); - if (user == null) { - return NetExceptionResponseFactory.error( - req, - 404, - "USER_NOT_FOUND", - "Пользователь не найден" - ); - } - - // 2) device key must match the user's stored deviceKey - String userDeviceKey = user.getDeviceKey(); - if (userDeviceKey == null || userDeviceKey.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "USER_DEVICE_KEY_EMPTY", - "У пользователя не задан deviceKey в БД" - ); - } - - if (!userDeviceKey.trim().equals(deviceKeyB64)) { - return NetExceptionResponseFactory.error( - req, - 403, - "DEVICE_KEY_MISMATCH", - "device_key не соответствует пользователю" - ); - } - - // 3) atomic upsert-if-newer - UserParamEntry e = new UserParamEntry( - login, - param, - timeMs, - value, - deviceKeyB64, - signatureB64 - ); - - int changed = paramsDAO.upsertIfNewer(c, e); - - Net_UpsertUserParam_Response resp = new Net_UpsertUserParam_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - if (changed == 1) { - log.info("✅ UpsertUserParam applied: login={}, param={}, time_ms={}", login, param, timeMs); - } else { - // 0 строк — значит в БД уже есть time_ms >= incoming - log.info("ℹ️ UpsertUserParam ignored (not newer): login={}, param={}, time_ms={}", login, param, timeMs); - } - - return resp; - } - - } catch (SQLException e) { - log.error("❌ DB error UpsertUserParam", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error UpsertUserParam", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; -import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; - -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; - -// --- 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; - -// --- 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; - -// --- NEW v2 entities --- -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.blockchain.Net_AddBlock_Handler; -import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; - -import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request; - -import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request; - -// --- NEW: SearchUsers --- -import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_SearchUsers_Request; - -import server.logic.ws_protocol.JSON.handlers.userParams.Net_GetUserParam_Handler; -import server.logic.ws_protocol.JSON.handlers.userParams.Net_ListUserParams_Handler; -import server.logic.ws_protocol.JSON.handlers.userParams.Net_UpsertUserParam_Handler; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Request; - -// --- NEW: connections friends lists --- -import server.logic.ws_protocol.JSON.handlers.connections.Net_GetFriendsLists_Handler; -import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Request; - -// --- NEW: Ping --- -import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler; -import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request; - -import java.util.Map; - -/** - * JsonHandlerRegistry — единое место, где руками регистрируются - * JSON-операции: op → handler и op → requestClass. - */ -public final class JsonHandlerRegistry { - - private static final Map HANDLERS = Map.ofEntries( - Map.entry("AddUser", new Net_AddUser_Handler()), - Map.entry("GetUser", new Net_GetUser_Handler()), - Map.entry("SearchUsers", new Net_SearchUsers_Handler()), - - // --- auth --- - Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()), - Map.entry("CreateAuthSession", new Net_CreateAuthSession__Handler()), - Map.entry("CloseActiveSession", new Net_CloseActiveSession_Handler()), - Map.entry("ListSessions", new Net_ListSessions_Handler()), - - // --- login to existing session in 2 steps --- - Map.entry("SessionChallenge", new Net_SessionChallenge_Handler()), - Map.entry("SessionLogin", new Net_SessionLogin_Handler()), - - // --- blockchain --- - Map.entry("AddBlock", new Net_AddBlock_Handler()), - - // --- userParams --- - Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()), - Map.entry("GetUserParam", new Net_GetUserParam_Handler()), - Map.entry("ListUserParams", new Net_ListUserParams_Handler()), - - // --- connections --- - Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()), - - // --- system --- - Map.entry("Ping", new Net_Ping_Handler()) - - // --- subscriptions --- -// Map.entry("ListSubscribedChannels", new Net_GetSubscribedChannels_Handler()) - ); - - private static final Map> REQUEST_TYPES = Map.ofEntries( - Map.entry("AddUser", Net_AddUser_Request.class), - Map.entry("GetUser", Net_GetUser_Request.class), - Map.entry("SearchUsers", Net_SearchUsers_Request.class), - - // --- auth --- - Map.entry("AuthChallenge", Net_AuthChallenge_Request.class), - Map.entry("CreateAuthSession", Net_CreateAuthSession_Request.class), - Map.entry("CloseActiveSession", Net_CloseActiveSession_Request.class), - Map.entry("ListSessions", Net_ListSessions_Request.class), - - // --- NEW v2 --- - Map.entry("SessionChallenge", Net_SessionChallenge_Request.class), - Map.entry("SessionLogin", Net_SessionLogin_Request.class), - - // --- blockchain --- - Map.entry("AddBlock", Net_AddBlock_Request.class), - - // --- userParams --- - Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class), - Map.entry("GetUserParam", Net_GetUserParam_Request.class), - Map.entry("ListUserParams", Net_ListUserParams_Request.class), - - // --- connections --- - Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class), - - // --- system --- - Map.entry("Ping", Net_Ping_Request.class) - ); - - private JsonHandlerRegistry() { } - - public static Map getHandlers() { - return HANDLERS; - } - - public static Map> getRequestTypes() { - return REQUEST_TYPES; - } -} -package server.logic.ws_protocol.JSON; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.entyties.Net_Exception_Response; -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.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; - -import java.util.Map; - -/** - * JsonInboundProcessor — обработка JSON-сообщений. - * - * 1) Парсит общий пакет (op, requestId, payload). - * 2) По op выбирает класс запроса и хэндлер. - * 3) Собирает "плоский" объект: op + requestId + поля из payload. - * 4) Маппит его в NetRequest через ObjectMapper. - * 5) Вызывает хэндлер, получает NetResponse. - * 6) Собирает JSON-ответ: - * { - * "op": ..., - * "requestId": ..., - * "status": ..., - * "payload": { все поля response, кроме op/requestId/status/payload } - * } - */ -public final class JsonInboundProcessor { - - private static final Logger log = LoggerFactory.getLogger(JsonInboundProcessor.class); - - private static final ObjectMapper JSON_MAPPER = new ObjectMapper() - .setSerializationInclusion(JsonInclude.Include.NON_NULL); - - private static final Map JSON_HANDLERS = - JsonHandlerRegistry.getHandlers(); - - private static final Map> JSON_REQUEST_TYPES = - JsonHandlerRegistry.getRequestTypes(); - - private JsonInboundProcessor() { - // utility - } - - public static String processJson(String json, ConnectionContext ctx) { - String op = null; - String requestId = null; - - // Для лога полезно знать, кто прислал (хотя бы login/sessionId, если есть) - String ctxLogin = safe(ctx != null ? ctx.getLogin() : null); - String ctxSessionId = safe(ctx != null ? ctx.getSessionId() : null); - - try { - if (json == null || json.isBlank()) { - Net_Exception_Response err = NetExceptionResponseFactory.error( - null, - null, - WireCodes.Status.BAD_REQUEST, - "EMPTY_JSON", - "Пустое JSON-сообщение" - ); - - String out = writeResponse(err); - - // DEBUG: что пришло / что ушло - if (log.isDebugEnabled()) { - log.debug("JSON IN (login={}, sessionId={}): ", ctxLogin, ctxSessionId); - log.debug("JSON OUT (login={}, sessionId={}): {}", ctxLogin, ctxSessionId, shorten(out, 1200)); - } - return out; - } - - // DEBUG: сырой вход (обрезаем, чтобы не убить лог) - if (log.isDebugEnabled()) { - log.debug("JSON IN (login={}, sessionId={}): {}", ctxLogin, ctxSessionId, shorten(json, 1200)); - } - - // 1) Парсим общий пакет - JsonNode root = JSON_MAPPER.readTree(json); - - // 2) op и requestId из корня - op = getTextOrNull(root, "op"); - requestId = getTextOrNull(root, "requestId"); - - if (op == null || op.isEmpty()) { - Net_Exception_Response err = NetExceptionResponseFactory.error( - null, - requestId, - WireCodes.Status.BAD_REQUEST, - "NO_OP", - "Поле 'op' отсутствует или пустое" - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - - JsonMessageHandler handler = JSON_HANDLERS.get(op); - Class reqClass = JSON_REQUEST_TYPES.get(op); - - if (handler == null || reqClass == null) { - Net_Exception_Response err = NetExceptionResponseFactory.error( - op, - requestId, - WireCodes.Status.BAD_REQUEST, - "UNKNOWN_OP", - "Неизвестная операция: " + op - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - - // 3) Берём payload - JsonNode payloadNode = root.get("payload"); - if (payloadNode == null || payloadNode.isNull()) { - Net_Exception_Response err = NetExceptionResponseFactory.error( - op, - requestId, - WireCodes.Status.BAD_REQUEST, - "NO_PAYLOAD", - "Поле 'payload' отсутствует" - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - if (!payloadNode.isObject()) { - Net_Exception_Response err = NetExceptionResponseFactory.error( - op, - requestId, - WireCodes.Status.BAD_REQUEST, - "BAD_PAYLOAD", - "Поле 'payload' должно быть объектом" - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - - // 3.1 Собираем "плоский" объект для маппинга в NetRequest: - // op + requestId + поля из payload - ObjectNode merged = JSON_MAPPER.createObjectNode(); - - // Добавляем op и requestId, чтобы они попали в NetRequest - merged.put("op", op); - if (requestId != null) merged.put("requestId", requestId); - - // Добавляем все поля из payload внутрь - merged.setAll((ObjectNode) payloadNode); - - // 4) Маппим в конкретный класс NetRequest - Net_Request request; - try { - request = JSON_MAPPER.treeToValue(merged, reqClass); - } catch (Exception mapErr) { - // Важно: вот это часто “теряется”, если не логировать отдельно - log.error("❌ JSON map error (op={}, requestId={}, login={}, sessionId={}): merged={}", - op, safe(requestId), ctxLogin, ctxSessionId, shorten(merged.toString(), 1200), mapErr); - - Net_Exception_Response err = NetExceptionResponseFactory.error( - op, - requestId, - WireCodes.Status.BAD_REQUEST, - "BAD_REQUEST_FORMAT", - "Некорректный формат запроса: не удалось распарсить поля payload" - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - - // DEBUG: нормализованный запрос (уже распарсен) - if (log.isDebugEnabled()) { - log.debug("REQ OBJ (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(safeToString(request), 1200)); - } - - // 5) Вызываем хэндлер - Net_Response response; - try { - response = handler.handle(request, ctx); - } catch (Exception handlerError) { - // ✅ Вот тут как раз и должны “появляться ошибки в логере” - log.error("💥 Handler error (op={}, requestId={}, login={}, sessionId={})", - op, safe(requestId), ctxLogin, ctxSessionId, handlerError); - - Net_Exception_Response err = NetExceptionResponseFactory.error( - op, - requestId, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_HANDLER_ERROR", - "Неожиданная ошибка при обработке операции: " + op - ); - - String out = writeResponse(err); - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - return out; - } - - // На всякий случай: если хэндлер не выставил op/requestId - if (response.getOp() == null) response.setOp(op); - if (response.getRequestId() == null) response.setRequestId(requestId); - - // 6) Универсальная сборка ответа - String out = writeResponse(response); - - // DEBUG: ответ ушёл - if (log.isDebugEnabled()) { - log.debug("RESP OBJ (login={}, sessionId={}, op={}, requestId={}, status={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), response.getStatus(), shorten(safeToString(response), 1200)); - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}, status={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), response.getStatus(), shorten(out, 1200)); - } - - return out; - - } catch (Exception e) { - // ✅ Любая неожиданная ошибка парсинга/обработки — в лог - log.error("❌ JSON processing error (op={}, requestId={}, login={}, sessionId={})", - safe(op), safe(requestId), safe(ctxLogin), safe(ctxSessionId), e); - - Net_Exception_Response err = NetExceptionResponseFactory.error( - op != null ? op : "Unknown", - requestId, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - - String out = writeResponse(err); - - if (log.isDebugEnabled()) { - log.debug("JSON OUT (login={}, sessionId={}, op={}, requestId={}): {}", - ctxLogin, ctxSessionId, safe(op), safe(requestId), shorten(out, 1200)); - } - - return out; - } - } - - // --- helpers --- - - private static String getTextOrNull(JsonNode node, String field) { - if (node == null || !node.has(field) || node.get(field).isNull()) return null; - return node.get(field).asText(); - } - - /** - * Унифицированная сериализация любого NetResponse в формат: - * { - * "op": ..., - * "requestId": ..., - * "status": ..., - * "payload": { ... } - * } - */ - private static String writeResponse(Net_Response response) { - try { - // Конвертируем полный объект ответа в ObjectNode - ObjectNode full = JSON_MAPPER.convertValue(response, ObjectNode.class); - - // То, что должно остаться наверху: - String op = full.hasNonNull("op") ? full.get("op").asText() : null; - String requestId = full.hasNonNull("requestId") ? full.get("requestId").asText() : null; - int status = full.hasNonNull("status") ? full.get("status").asInt() : 0; - - // Удаляем базовые поля и payload из "полного" объекта, - // всё остальное отправляем внутрь payload. - full.remove("op"); - full.remove("requestId"); - full.remove("status"); - full.remove("payload"); - - ObjectNode root = JSON_MAPPER.createObjectNode(); - if (op != null) root.put("op", op); else root.putNull("op"); - if (requestId != null) root.put("requestId", requestId); else root.putNull("requestId"); - root.put("status", status); - - // payload — это всё, что осталось от full (может быть пустым объектом {}) - root.set("payload", full); - - return JSON_MAPPER.writeValueAsString(root); - - } catch (Exception e) { - // Совсем аварийный случай — сериализация ответа сломалась. - log.error("❌ Response serialization error (op={}, requestId={})", - safe(response != null ? response.getOp() : null), - safe(response != null ? response.getRequestId() : null), - e); - - return "{\"op\":\"" + safe(response != null ? response.getOp() : null) + - "\",\"requestId\":\"" + safe(response != null ? response.getRequestId() : null) + - "\",\"status\":" + (response != null ? response.getStatus() : 500) + - ",\"payload\":{\"code\":\"SERIALIZATION_ERROR\",\"message\":\"Ошибка сериализации ответа\"}}"; - } - } - - private static String safe(String s) { - return s != null ? s : ""; - } - - private static String shorten(String s, int max) { - if (s == null) return ""; - if (s.length() <= max) return s; - return s.substring(0, Math.max(0, max)) + "...(+" + (s.length() - max) + " chars)"; - } - - private static String safeToString(Object o) { - if (o == null) return "null"; - try { - // Чтобы не плодить огромные логи и не утыкаться в циклические ссылки — - // логируем как JSON, если возможно. - return JSON_MAPPER.writeValueAsString(o); - } catch (Exception ignore) { - return String.valueOf(o); - } - } -} -////package server.logic.ws_protocol.JSON.utils; -// -//import shine.db.entities.SolanaUserEntry; -//import utils.crypto.Ed25519Util; -// -//import java.nio.charset.StandardCharsets; -//import java.util.Base64; -// -//public final class AuthSignatures { -// -// private AuthSignatures() {} -// -// /** preimage для CreateAuthSession(v2): "AUTH_CREATE_SESSION:login:timeMs:authNonce" */ -// public static byte[] preimageCreateAuthSession(String login, long timeMs, String authNonce) { -// String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce; -// return preimageStr.getBytes(StandardCharsets.UTF_8); -// } -// -// /** Декод base64 / base64url (если надо — подстрой под твой decodeBase64Any) */ -// public static byte[] decodeBase64Any(String s) throws IllegalArgumentException { -// if (s == null) throw new IllegalArgumentException("base64 is null"); -// String x = s.trim(); -// if (x.isEmpty()) throw new IllegalArgumentException("base64 is empty"); -// -// try { -// return Base64.getDecoder().decode(x); -// } catch (IllegalArgumentException e1) { -// // пробуем base64url без паддинга -// return Base64.getUrlDecoder().decode(x); -// } -// } -// -// /** -// * Проверка подписи CreateAuthSession(v2) по deviceKey пользователя. -// * Подпись проверяется над preimageCreateAuthSession(...). -// */ -// public static boolean verifyCreateAuthSessionSignature( -// SolanaUserEntry user, -// String login, -// String authNonce, -// long timeMs, -// String signatureB64 -// ) throws IllegalArgumentException { -// -// // user.getDeviceKey() — base64 публичного ключа (32 байта) -// byte[] publicKey32 = decodeBase64Any(user.getDeviceKey()); -// byte[] signature64 = decodeBase64Any(signatureB64); -// -// byte[] preimage = preimageCreateAuthSession(login, timeMs, authNonce); -// return Ed25519Util.verify(preimage, signature64, publicKey32); -// } -//} -package server.logic.ws_protocol.JSON.utils; - -import server.logic.ws_protocol.JSON.entyties.Net_Exception_Response; -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Фабрика ошибок для JSON-протокола. - * Создаёт единообразные NetExceptionResponse. - */ -public final class NetExceptionResponseFactory { - - private NetExceptionResponseFactory() { - // запрет на создание объектов - } - - public static Net_Exception_Response error(Net_Request req, - int status, - String code, - String message) { - - Net_Exception_Response resp = new Net_Exception_Response(); - - // ✅ НЕ падаем, даже если req == null - if (req != null) { - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - } else { - resp.setOp(null); - resp.setRequestId(null); - } - - resp.setStatus(status); - resp.setCode(code); - resp.setMessage(message); - return resp; - } - - /** - * Вариант для случаев, когда NetRequest ещё не распарсен, - * но мы уже знаем op и requestId (или они null). - */ - public static Net_Exception_Response error(String op, - String requestId, - int status, - String code, - String message) { - - Net_Exception_Response resp = new Net_Exception_Response(); - resp.setOp(op); - resp.setRequestId(requestId); - resp.setStatus(status); - resp.setCode(code); - resp.setMessage(message); - return resp; - } -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/concat_to_file.sh b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/concat_to_file.sh deleted file mode 100755 index f6db1f1..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/concat_to_file.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# собрать только *.java файлы и вывести их содержимое в файл -find . -type f -name "*.java" | sort | while read -r f; do - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Все .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/all_files.txt b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/all_files.txt deleted file mode 100644 index 25f556f..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/all_files.txt +++ /dev/null @@ -1,140 +0,0 @@ -package server.logic.ws_protocol.JSON.entyties; - -/** - * Базовый класс для всех событий (event). - * Общие поля: op и payload. - *. - * Формат JSON (event): - * { - * "op": "...", - * "payload": { ... } - * } - */ -public abstract class Net_Event { - - /** Имя операции / события (op). */ - private String op; - - /** - * Произвольные данные. - * В JSON это поле "payload". - */ - private Object payload; - - // --- getters / setters --- - - public String getOp() { - return op; - } - - public void setOp(String op) { - this.op = op; - } - - public Object getPayload() { - return payload; - } - - public void setPayload(Object payload) { - this.payload = payload; - } -} - -package server.logic.ws_protocol.JSON.entyties; - -/** - * Ответ с ошибкой (любой отказ). - *. - * В payload будет: - * { - * "code": "...", - * "message": "..." - * } - */ -public class Net_Exception_Response extends Net_Response { - - private String code; - private String message; - - public String getCode() { - return code; - } - - public void setCode(String code) { - this.code = code; - } - - public String getMessage() { - return message; - } - - public void setMessage(String message) { - this.message = message; - } -} - -package server.logic.ws_protocol.JSON.entyties; - -/** - * Базовый класс для всех запросов (client → server). - *. - * Наследуется от NetEvent и добавляет requestId. - *. - * Формат JSON (request): - * { - * "op": "...", - * "requestId": "...", - * "payload": { ... } - * } - */ -public abstract class Net_Request extends Net_Event { - - /** Идентификатор запроса, чтобы связать запрос и ответ. */ - private String requestId; - - // --- getters / setters --- - - public String getRequestId() { - return requestId; - } - - public void setRequestId(String requestId) { - this.requestId = requestId; - } -} - -package server.logic.ws_protocol.JSON.entyties; - -/** - * Базовый класс для всех ответов (server → client). - *. - * Наследуется от NetRequest и добавляет status. - *. - * Формат JSON (response): - * { - * "op": "...", - * "requestId": "...", - * "status": 200, - * "payload": { ... } // и для успеха, и для ошибки - * } - */ -public abstract class Net_Response extends Net_Request { - - /** Статус результата (200 — успех, любое другое значение — ошибка). */ - private int status; - - // --- getters / setters --- - - public int getStatus() { - return status; - } - - public void setStatus(int status) { - this.status = status; - } - - public boolean isOk() { - return status == 200; - } -} - diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/concat_to_file.sh b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/concat_to_file.sh deleted file mode 100755 index f6db1f1..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/entyties/concat_to_file.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# собрать только *.java файлы и вывести их содержимое в файл -find . -type f -name "*.java" | sort | while read -r f; do - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Все .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/all_files.txt b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/all_files.txt deleted file mode 100644 index 397359c..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/all_files.txt +++ /dev/null @@ -1,3475 +0,0 @@ -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 1 авторизации: запрос выдачи одноразового nonce (authNonce). - * - * Клиент по логину просит сервер сгенерировать случайный authNonce, - * который будет использован на втором шаге при подписи. - * - * Формат входящего JSON: - * { - * "op": "AuthChallenge", - * "requestId": "...", - * "payload": { - * "login": "someLogin" - * } - * } - * - * Формат успешного ответа: - * { - * "op": "AuthChallenge", - * "requestId": "...", - * "status": 200, - * "payload": { - * "authNonce": "base64-строка-от-32-байт" - * } - * } - */ -public class Net_AuthChallenge_Request extends Net_Request { - - /** - * Логин пользователя, для которого запускается авторизация. - */ - private String login; - - public String getLogin() { - return login; - } - public void setLogin(String login) { - this.login = login; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на AuthChallenge. - * - * При успехе сервер возвращает одноразовый nonce для подписи (authNonce), - * который клиент обязан использовать на втором шаге при формировании строки - * для цифровой подписи. - * - * JSON: - * { - * "op": "AuthChallenge", - * "requestId": "...", - * "status": 200, - * "payload": { - * "authNonce": "base64-строка-от-32-байт" - * } - * } - */ -public class Net_AuthChallenge_Response extends Net_Response { - - /** - * Одноразовый nonce для авторификации. - * Строка — это base64-представление 32 случайных байт. - */ - private String authNonce; - - public String getAuthNonce() { - return authNonce; - } - - public void setAuthNonce(String authNonce) { - this.authNonce = authNonce; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос CloseActiveSession — закрытие активной сессии пользователя. - * - * Новая логика (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Никаких подписей и "AUTH_IN_PROGRESS" здесь больше нет. - * - * payload: - * { - * "sessionId": "..." // опционально; если пусто — закрываем текущую - * } - */ -public class Net_CloseActiveSession_Request extends Net_Request { - - /** Идентификатор сессии, которую нужно закрыть. Может быть пустым. */ - private String sessionId; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на CloseActiveSession. - * - * При успехе: - * - status = 200; - * - payload = {}. - * - * Закрытие WebSocket-соединения может быть выполнено сразу (для другой сессии) - * или чуть позже (для текущей сессии) после отправки ответа. - */ -public class Net_CloseActiveSession_Response extends Net_Response { - // Дополнительных полей пока не требуется. -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 2 (v2): создание новой сессии ТОЛЬКО через deviceKey. - * - * Шаги: - * 1) AuthChallenge(login) -> authNonce - * 2) CreateAuthSession(storagePwd, sessionPubKeyB64, timeMs, signatureB64, clientInfo) - * - * Подпись deviceKey делается над строкой (UTF-8): - * AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce}:{sessionPubKeyB64}:{storagePwd} - * - * Важно: - * - sessionKey генерируется на клиенте, на сервер отправляется ТОЛЬКО sessionPubKeyB64 (32 bytes base64). - * - В БД active_sessions.session_key хранится sessionPubKeyB64. - */ -public class Net_CreateAuthSession_Request extends Net_Request { - - /** Клиентский пароль для хранения данных (base64 от 32 байт). */ - private String storagePwd; - - /** Публичный ключ сессии (sessionPubKey), base64 от 32 байт. */ - private String sessionPubKeyB64; - - /** Время на стороне клиента (мс с 1970-01-01). */ - private long timeMs; - - /** Подпись Ed25519(deviceKey) над строкой AUTH_CREATE_SESSION:... (base64). */ - private String signatureB64; - - /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ - private String clientInfo; - - public String getStoragePwd() { - return storagePwd; - } - - public void setStoragePwd(String storagePwd) { - this.storagePwd = storagePwd; - } - - public String getSessionPubKeyB64() { - return sessionPubKeyB64; - } - - public void setSessionPubKeyB64(String sessionPubKeyB64) { - this.sessionPubKeyB64 = sessionPubKeyB64; - } - - public long getTimeMs() { - return timeMs; - } - - public void setTimeMs(long timeMs) { - this.timeMs = timeMs; - } - - public String getSignatureB64() { - return signatureB64; - } - - public void setSignatureB64(String signatureB64) { - this.signatureB64 = signatureB64; - } - - public String getClientInfo() { - return clientInfo; - } - - public void setClientInfo(String clientInfo) { - this.clientInfo = clientInfo; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на CreateAuthSession (v2). - * - * При успехе сервер создаёт запись в active_sessions - * и возвращает идентификатор сессии sessionId. - * - * JSON: - * { - * "op": "CreateAuthSession", - * "requestId": "...", - * "status": 200, - * "payload": { - * "sessionId": "base64(32)" - * } - * } - */ -public class Net_CreateAuthSession_Response extends Net_Response { - - /** Идентификатор сессии, base64 от 32 байт. */ - private String sessionId; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос ListSessions — список активных сессий пользователя. - * - * Новая логика (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Пустой payload. - */ -public class Net_ListSessions_Request extends Net_Request { - // пусто -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.List; - -/** - * Ответ на ListSessions. - * - * При успехе: - * - status = 200; - * - payload: - * { - * "sessions": [ - * { - * "sessionId": "...", - * "clientInfoFromClient": "...", - * "clientInfoFromRequest": "...", - * "geo": "Country, City" | "unknown", - * "lastAuthirificatedAtMs": 1733310000000 - * }, - * ... - * ] - * } - */ -public class Net_ListSessions_Response extends Net_Response { - - /** - * Список активных сессий для текущего пользователя. - */ - private List sessions; - - public List getSessions() { - return sessions; - } - - public void setSessions(List sessions) { - this.sessions = sessions; - } - - /** - * Описание одной активной сессии. - */ - public static class SessionInfo { - - /** Идентификатор сессии, base64 от 32 байт. */ - private String sessionId; - - /** Что прислал клиент в CreateAuthSession/RefreshSession (clientInfo). */ - private String clientInfoFromClient; - - /** Краткая строка, собранная сервером из HTTP-запроса (UA, платформа и т.п.). */ - private String clientInfoFromRequest; - - /** Строка геолокации вида "Country, City" или "unknown". */ - private String geo; - - /** Время последней успешной авторизации/refresh (мс с 1970-01-01). */ - private long lastAuthirificatedAtMs; - - // --- getters / setters --- - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } - - public String getClientInfoFromClient() { - return clientInfoFromClient; - } - - public void setClientInfoFromClient(String clientInfoFromClient) { - this.clientInfoFromClient = clientInfoFromClient; - } - - public String getClientInfoFromRequest() { - return clientInfoFromRequest; - } - - public void setClientInfoFromRequest(String clientInfoFromRequest) { - this.clientInfoFromRequest = clientInfoFromRequest; - } - - public String getGeo() { - return geo; - } - - public void setGeo(String geo) { - this.geo = geo; - } - - public long getLastAuthirificatedAtMs() { - return lastAuthirificatedAtMs; - } - - public void setLastAuthirificatedAtMs(long lastAuthirificatedAtMs) { - this.lastAuthirificatedAtMs = lastAuthirificatedAtMs; - } - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 1 входа в существующую сессию (v2): - * SessionChallenge(sessionId) -> nonce - */ -public class Net_SessionChallenge_Request extends Net_Request { - - private String sessionId; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на SessionChallenge (v2). - * payload: { "nonce": "base64(32)" } - */ -public class Net_SessionChallenge_Response extends Net_Response { - - private String nonce; - - public String getNonce() { - return nonce; - } - - public void setNonce(String nonce) { - this.nonce = nonce; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 2 входа в существующую сессию (v2): - * SessionLogin(sessionId, timeMs, signatureB64) -> storagePwd, AUTH_STATUS_USER - * - * Подпись делается sessionKey (приватный ключ на устройстве) над строкой (UTF-8): - * SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} - * - * nonce берётся из SessionChallenge и хранится в ctx (одноразовый, TTL). - */ -public class Net_SessionLogin_Request extends Net_Request { - - private String sessionId; - private long timeMs; - private String signatureB64; - - /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ - private String clientInfo; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } - - public long getTimeMs() { - return timeMs; - } - - public void setTimeMs(long timeMs) { - this.timeMs = timeMs; - } - - public String getSignatureB64() { - return signatureB64; - } - - public void setSignatureB64(String signatureB64) { - this.signatureB64 = signatureB64; - } - - public String getClientInfo() { - return clientInfo; - } - - public void setClientInfo(String clientInfo) { - this.clientInfo = clientInfo; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на SessionLogin (v2). - * payload: { "storagePwd": "base64(32)" } - */ -public class Net_SessionLogin_Response extends Net_Response { - - private String storagePwd; - - public String getStoragePwd() { - return storagePwd; - } - - public void setStoragePwd(String storagePwd) { - this.storagePwd = storagePwd; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import server.logic.ws_protocol.Base64Ws; -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_AuthChallenge_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; - -import java.security.SecureRandom; - -/** - * AuthChallenge (v2) — шаг 1 создания новой сессии. - * - * Логика авторизации (v2): - * - Создание новой сессии возможно ТОЛЬКО через deviceKey пользователя. - * - Этот handler выдаёт одноразовый authNonce, который клиент использует во втором шаге: - * CreateAuthSession(..., signature(deviceKey, AUTH_CREATE_SESSION:...)) - * - * Что делает: - * 1) Проверяет login. - * 2) Находит пользователя (solana_users). - * 3) Пишет solanaUser в ctx, ставит AUTH_STATUS_AUTH_IN_PROGRESS. - * 4) Генерирует authNonce (base64url(32)) и сохраняет в ctx.authNonce. - */ -public class Net_AuthChallenge_Handler implements JsonMessageHandler { - - private static final SecureRandom RANDOM = new SecureRandom(); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - - Net_AuthChallenge_Request req = (Net_AuthChallenge_Request) baseReq; - - String login = req.getLogin(); - if (login == null || login.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_LOGIN", - "Пустой логин" - ); - } - - // Если по этому соединению уже есть залогиненный пользователь — не даём повторную авторификацию - if (ctx.getLogin() != null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "ALREADY_AUTHED", - "Попытка повторной авторификации для уже заданного login=" + ctx.getLogin() - ); - } - - SolanaUserEntry solanaUserEntry = SolanaUsersDAO.getInstance().getByLogin(login); - if (solanaUserEntry == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "UNKNOWN_USER", - "Пользователь с таким логином не найден" - ); - } - - ctx.setSolanaUser(solanaUserEntry); - ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS); - - byte[] buf = new byte[32]; - RANDOM.nextBytes(buf); - String authNonce = Base64Ws.encode(buf); - - ctx.setAuthNonce(authNonce); - - Net_AuthChallenge_Response resp = new Net_AuthChallenge_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setAuthNonce(authNonce); - - return resp; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -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_CloseActiveSession_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import server.ws.WsConnectionUtils; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; - -import java.sql.SQLException; - -/** - * CloseActiveSession (v2) — закрытие текущей или другой сессии. - * - * Логика авторизации (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Никаких подписей и AUTH_IN_PROGRESS здесь больше нет. - * - * Закрытие: - * - удаляем запись из БД - * - если по sessionId есть активный WS — закрываем его - */ -public class Net_CloseActiveSession_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_CloseActiveSession_Handler.class); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_CloseActiveSession_Request req = (Net_CloseActiveSession_Request) baseReq; - - if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "NOT_AUTHENTICATED", - "Операция доступна только для авторизованных пользователей" - ); - } - - SolanaUserEntry user = ctx.getSolanaUser(); - String currentLogin = user.getLogin(); - - String targetSessionId = req.getSessionId(); - if (targetSessionId == null || targetSessionId.isBlank()) { - if (ctx.getSessionId() != null && !ctx.getSessionId().isBlank()) { - targetSessionId = ctx.getSessionId(); - } else if (ctx.getActiveSession() != null && ctx.getActiveSession().getSessionId() != null) { - targetSessionId = ctx.getActiveSession().getSessionId(); - } else { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_SESSION_TO_CLOSE", - "Не удалось определить, какую сессию нужно закрыть" - ); - } - } - - ActiveSessionEntry targetSession; - try { - targetSession = ActiveSessionsDAO.getInstance().getBySessionId(targetSessionId); - } catch (SQLException e) { - log.error("Ошибка БД при поиске сессии для CloseActiveSession sessionId={}", targetSessionId, e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка доступа к базе данных при поиске сессии" - ); - } - - if (targetSession == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_NOT_FOUND", - "Сессия для закрытия не найдена" - ); - } - - if (currentLogin == null || !currentLogin.equals(targetSession.getLogin())) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_OF_ANOTHER_USER", - "Нельзя закрывать сессию другого пользователя" - ); - } - - boolean isCurrentSession = targetSessionId.equals(ctx.getSessionId()); - - closeActiveSession(targetSessionId, ctx, isCurrentSession); - - Net_CloseActiveSession_Response resp = new Net_CloseActiveSession_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - return resp; - } - - private void closeActiveSession(String targetSessionId, - ConnectionContext currentCtx, - boolean isCurrentSession) { - - try { - ActiveSessionsDAO.getInstance().deleteBySessionId(targetSessionId); - } catch (SQLException e) { - log.error("Ошибка БД при удалении сессии sessionId={}", targetSessionId, e); - } - - ConnectionContext ctxToClose = - ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId); - - if (ctxToClose == null) return; - - if (isCurrentSession && ctxToClose == currentCtx) { - new Thread(() -> { - try { Thread.sleep(50); } catch (InterruptedException ignored) {} - WsConnectionUtils.closeConnection( - ctxToClose, - 4000, - "Session closed by client via CloseActiveSession" - ); - }, "CloseSession-" + targetSessionId).start(); - } else { - WsConnectionUtils.closeConnection( - ctxToClose, - 4000, - "Session closed by client via CloseActiveSession" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.Base64Ws; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -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_CreateAuthSession_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import server.ws.WsConnectionUtils; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; -import shine.geo.ClientInfoService; -import shine.geo.GeoLookupService; -import utils.crypto.Ed25519Util; - -import org.eclipse.jetty.websocket.api.Session; - -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.sql.SQLException; - -/** - * CreateAuthSession (v2) — шаг 2 создания новой сессии (ТОЛЬКО deviceKey). - * - * Логика авторизации (v2): - * - Создание сессии: AuthChallenge(login) -> authNonce -> CreateAuthSession(...) - * - Клиент генерирует sessionKey (Ed25519), хранит приватный ключ у себя, - * отправляет на сервер ТОЛЬКО sessionPubKeyB64. - * - Сервер сохраняет sessionPubKeyB64 в active_sessions.session_key. - * - * Подпись deviceKey (Ed25519) проверяется над строкой (UTF-8): - * AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce} - * - * На выходе: - * - создаётся запись active_sessions - * - ctx становится AUTH_STATUS_USER (вход выполнен как "текущая сессия") - * - ответ: sessionId - */ -public class Net_CreateAuthSession__Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class); - private static final SecureRandom RANDOM = new SecureRandom(); - - public static final long ALLOWED_SKEW_MS = 30_000L; - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - - Net_CreateAuthSession_Request req = (Net_CreateAuthSession_Request) baseReq; - - if (ctx == null - || ctx.getSolanaUser() == null - || ctx.getAuthNonce() == null - || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS) { - - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_STEP1_CONTEXT", - "Шаг 1 авторизации не был корректно выполнен для данного соединения" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no step1 context or bad auth state"); - return err; - } - - SolanaUserEntry user = ctx.getSolanaUser(); - String login = user.getLogin(); - if (login == null || login.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "NO_LOGIN", - "Для пользователя не задан login в БД" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no login"); - return err; - } - - String storagePwd = req.getStoragePwd(); - if (storagePwd == null || storagePwd.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_STORAGE_PWD", - "Пустой storagePwd" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty storagePwd"); - return err; - } - - String sessionPubKeyB64 = req.getSessionPubKeyB64(); - if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SESSION_PUBKEY", - "Пустой sessionPubKeyB64" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty session pubkey"); - return err; - } - - // Проверим, что sessionPubKeyB64 декодируется в 32 байта - byte[] sessionPubKey32; - try { - sessionPubKey32 = Base64Ws.decode(sessionPubKeyB64); - } catch (IllegalArgumentException e) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "Некорректный base64 в sessionPubKeyB64" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session pubkey base64"); - return err; - } - if (sessionPubKey32.length != 32) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_SESSION_PUBKEY_LEN", - "sessionPubKey должен быть 32 байта" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session pubkey length"); - return err; - } - - String signatureB64 = req.getSignatureB64(); - if (signatureB64 == null || signatureB64.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SIGNATURE", - "Пустая цифровая подпись" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty signature"); - return err; - } - - long timeMs = req.getTimeMs(); - long nowMs = System.currentTimeMillis(); - long diff = Math.abs(nowMs - timeMs); - if (diff > ALLOWED_SKEW_MS) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "TIME_SKEW", - "Время клиента отличается от сервера более чем на 30 секунд" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: time skew"); - return err; - } - - String clientInfoFromClient = req.getClientInfo(); - if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) { - clientInfoFromClient = clientInfoFromClient.substring(0, 50); - } - - String devicePubKeyB64 = user.getDeviceKey(); - if (devicePubKeyB64 == null || devicePubKeyB64.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_DEVICE_KEY", - "Отсутствует deviceKey у пользователя" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no deviceKey"); - return err; - } - - String authNonce = ctx.getAuthNonce(); - - boolean sigOk; - try { - sigOk = verifyCreateSessionSignature( - user, - login, - authNonce, - timeMs, - signatureB64 - ); - } catch (IllegalArgumentException ex) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "Некорректный формат Base64 для ключа или подписи" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad base64"); - return err; - } - - if (!sigOk) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "BAD_SIGNATURE", - "Подпись не прошла проверку" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad signature"); - return err; - } - - // --- генерируем sessionId --- - String sessionId = generateRandom32B64Url(); - long now = System.currentTimeMillis(); - - // --- Сбор данных о клиенте (IP, UA, язык) --- - Session wsSession = ctx.getWsSession(); - String clientInfoFromRequest = ClientInfoService.buildClientInfoString(wsSession); - String userLanguage = ClientInfoService.extractPreferredLanguageTag(wsSession); - - String clientIp = ""; - if (wsSession != null) { - String ip = ClientInfoService.extractClientIp(wsSession); - if (ip != null) clientIp = ip; - - if (!clientIp.isBlank()) { - try { - GeoLookupService.resolveCountryCityOrIpWithCache(clientIp); - } catch (Exception e) { - log.debug("Geo lookup failed for ip={}", clientIp, e); - } - } - } - - // --- создаём запись ActiveSession и сохраняем в БД --- - ActiveSessionsDAO dao = ActiveSessionsDAO.getInstance(); - ActiveSessionEntry activeSessionEntry; - - try { - activeSessionEntry = new ActiveSessionEntry( - sessionId, - login, - sessionPubKeyB64, // session_key (pubkey) - storagePwd, - now, - now, - null, // pushEndpoint - null, // pushP256dhKey - null, // pushAuthKey - clientIp, - clientInfoFromClient, - clientInfoFromRequest, - userLanguage - ); - - dao.insert(activeSessionEntry); - } catch (SQLException e) { - log.error("Ошибка БД при создании новой сессии для login={}", login, e); - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR_SESSION_CREATE", - "Ошибка БД при создании сессии" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: db error"); - return err; - } - - // --- обновляем контекст --- - ctx.setActiveSession(activeSessionEntry); - ctx.setSessionId(sessionId); - ctx.setAuthNonce(null); - ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); - - ActiveConnectionsRegistry.getInstance().register(ctx); - - // --- формируем ответ --- - Net_CreateAuthSession_Response resp = new Net_CreateAuthSession_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setSessionId(sessionId); - return resp; - } - - private static boolean verifyCreateSessionSignature( - SolanaUserEntry user, - String login, - String authNonce, - long timeMs, - String signatureB64 - ) throws IllegalArgumentException { - - // deviceKey (pub, 32) - byte[] publicKey32 = Ed25519Util.keyFromBase64(user.getDeviceKey()); - byte[] signature64 = Base64Ws.decode(signatureB64); - - String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce; - byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); - - return Ed25519Util.verify(preimage, signature64, publicKey32); - } - - private static String generateRandom32B64Url() { - byte[] buf = new byte[32]; - RANDOM.nextBytes(buf); - return Base64Ws.encode(buf); - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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_ListSessions_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Response; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Response.SessionInfo; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; -import shine.geo.GeoLookupService; - -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.List; - -/** - * ListSessions (v2) — список активных сессий. - * - * Логика авторизации (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Никаких подписей здесь больше нет. - */ -public class Net_ListSessions_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_ListSessions_Handler.class); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_ListSessions_Request req = (Net_ListSessions_Request) baseReq; - - if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "NOT_AUTHENTICATED", - "Операция доступна только для авторизованных пользователей" - ); - } - - SolanaUserEntry user = ctx.getSolanaUser(); - String currentLogin = user.getLogin(); - - List sessions; - try { - sessions = ActiveSessionsDAO.getInstance().getByLogin(currentLogin); - } catch (SQLException e) { - log.error("Ошибка БД при получении списка сессий для login={}", currentLogin, e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR_LIST_SESSIONS", - "Ошибка доступа к базе данных при получении списка сессий" - ); - } - - List resultList = new ArrayList<>(); - for (ActiveSessionEntry s : sessions) { - SessionInfo info = new SessionInfo(); - info.setSessionId(s.getSessionId()); - info.setClientInfoFromClient(s.getClientInfoFromClient()); - info.setClientInfoFromRequest(s.getClientInfoFromRequest()); - info.setLastAuthirificatedAtMs(s.getLastAuthirificatedAtMs()); - - String ip = s.getClientIp(); - String geo = GeoLookupService.resolveCountryCityOrIpWithCache(ip); - info.setGeo(geo); - - resultList.add(info); - } - - Net_ListSessions_Response resp = new Net_ListSessions_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setSessions(resultList); - - return resp; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import server.logic.ws_protocol.Base64Ws; -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_SessionChallenge_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; - -import java.security.SecureRandom; -import java.sql.SQLException; - -/** - * SessionChallenge (v2) — шаг 1 входа в существующую сессию. - * - * Логика авторизации (v2): - * - Вход в существующую сессию ВСЕГДА в 2 шага: - * 1) SessionChallenge(sessionId) -> nonce - * 2) SessionLogin(sessionId, timeMs, signature(sessionKey, SESSION_LOGIN:...)) - * - * Что делает: - * - Проверяет, что sessionId существует в БД. - * - Генерирует одноразовый nonce (base64url(32)), сохраняет его в ctx: - * ctx.sessionLoginNonce, ctx.sessionLoginSessionId, ctx.sessionLoginNonceExpiresAtMs. - */ -public class Net_SessionChallenge_Handler implements JsonMessageHandler { - - private static final SecureRandom RANDOM = new SecureRandom(); - private static final long NONCE_TTL_MS = 60_000L; - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_SessionChallenge_Request req = (Net_SessionChallenge_Request) baseReq; - - String sessionId = req.getSessionId(); - if (sessionId == null || sessionId.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SESSION_ID", - "Пустой sessionId" - ); - } - - ActiveSessionEntry session; - try { - session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId); - } catch (SQLException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка доступа к базе данных" - ); - } - - if (session == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_NOT_FOUND", - "Сессия не найдена" - ); - } - - byte[] buf = new byte[32]; - RANDOM.nextBytes(buf); - String nonce = Base64Ws.encode(buf); - - long now = System.currentTimeMillis(); - ctx.setSessionLoginNonce(nonce); - ctx.setSessionLoginSessionId(sessionId); - ctx.setSessionLoginNonceExpiresAtMs(now + NONCE_TTL_MS); - - Net_SessionChallenge_Response resp = new Net_SessionChallenge_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setNonce(nonce); - return resp; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.Base64Ws; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -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_SessionLogin_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; -import shine.geo.ClientInfoService; -import shine.geo.GeoLookupService; -import utils.crypto.Ed25519Util; - -import java.nio.charset.StandardCharsets; -import java.sql.SQLException; - -/** - * SessionLogin (v2) — шаг 2 входа в существующую сессию (по sessionKey). - * - * Логика авторизации (v2): - * - SessionChallenge(sessionId) выдаёт nonce (одноразовый, TTL). - * - SessionLogin проверяет подпись sessionKey над строкой: - * SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} - * - sessionPubKey берём из БД: active_sessions.session_key (base64 32 bytes). - * - * При успехе: - * - ctx становится AUTH_STATUS_USER - * - обновляем метаданные сессии (lastAuth + clientIp + clientInfo + lang) - * - возвращаем storagePwd - */ -public class Net_SessionLogin_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_SessionLogin_Handler.class); - - private static final long ALLOWED_SKEW_MS = 30_000L; - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_SessionLogin_Request req = (Net_SessionLogin_Request) baseReq; - - String sessionId = req.getSessionId(); - if (sessionId == null || sessionId.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SESSION_ID", - "Пустой sessionId" - ); - } - - // проверка челленджа - if (ctx.getSessionLoginNonce() == null - || ctx.getSessionLoginSessionId() == null - || System.currentTimeMillis() > ctx.getSessionLoginNonceExpiresAtMs()) { - - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_CHALLENGE", - "Нет активного SessionChallenge или nonce истёк" - ); - } - - if (!sessionId.equals(ctx.getSessionLoginSessionId())) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "SESSION_ID_MISMATCH", - "nonce был выдан для другого sessionId" - ); - } - - long timeMs = req.getTimeMs(); - long nowMs = System.currentTimeMillis(); - if (Math.abs(nowMs - timeMs) > ALLOWED_SKEW_MS) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "TIME_SKEW", - "Время клиента отличается от сервера более чем на 30 секунд" - ); - } - - String signatureB64 = req.getSignatureB64(); - if (signatureB64 == null || signatureB64.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SIGNATURE", - "Пустая подпись" - ); - } - - ActiveSessionEntry session; - try { - session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId); - } catch (SQLException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка доступа к базе данных" - ); - } - - if (session == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_NOT_FOUND", - "Сессия не найдена" - ); - } - - String sessionPubKeyB64 = session.getSessionKey(); // это pubKey (Base64(32)) - if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "NO_SESSION_KEY", - "В сессии не задан session_key" - ); - } - - String nonce = ctx.getSessionLoginNonce(); - - boolean sigOk; - try { - sigOk = verifySessionLoginSignature(sessionPubKeyB64, sessionId, timeMs, nonce, signatureB64); - } catch (IllegalArgumentException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "Некорректный Base64 для ключа/подписи" - ); - } - - if (!sigOk) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "BAD_SIGNATURE", - "Подпись не прошла проверку" - ); - } - - // сжигаем nonce - ctx.setSessionLoginNonce(null); - ctx.setSessionLoginSessionId(null); - ctx.setSessionLoginNonceExpiresAtMs(0); - - // подтягиваем пользователя - SolanaUserEntry user; - try { - user = SolanaUsersDAO.getInstance().getByLogin(session.getLogin()); - } catch (SQLException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR_USER_LOOKUP", - "Ошибка доступа к базе данных при получении пользователя" - ); - } - - if (user == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "USER_NOT_FOUND_FOR_SESSION", - "Пользователь для данной сессии не найден" - ); - } - - // обновление метаданных - String clientInfoFromClient = req.getClientInfo(); - if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) { - clientInfoFromClient = clientInfoFromClient.substring(0, 50); - } - - String clientIp = null; - String clientInfoFromRequest = null; - String userLanguage = null; - - if (ctx.getWsSession() != null) { - clientIp = ClientInfoService.extractClientIp(ctx.getWsSession()); - clientInfoFromRequest = ClientInfoService.buildClientInfoString(ctx.getWsSession()); - userLanguage = ClientInfoService.extractPreferredLanguageTag(ctx.getWsSession()); - - if (clientIp != null && !clientIp.isBlank()) { - try { - GeoLookupService.resolveCountryCityOrIpWithCache(clientIp); - } catch (Exception e) { - log.debug("Geo lookup failed for ip={}", clientIp, e); - } - } - } - - long now = System.currentTimeMillis(); - try { - ActiveSessionsDAO.getInstance().updateOnRefresh( - sessionId, - now, - clientIp, - clientInfoFromClient, - clientInfoFromRequest, - userLanguage - ); - } catch (SQLException e) { - log.error("Ошибка БД при updateOnRefresh sessionId={}", sessionId, e); - } - - session.setLastAuthirificatedAtMs(now); - session.setClientIp(clientIp); - session.setClientInfoFromClient(clientInfoFromClient); - session.setClientInfoFromRequest(clientInfoFromRequest); - session.setUserLanguage(userLanguage); - - // ctx - ctx.setActiveSession(session); - ctx.setSolanaUser(user); - ctx.setSessionId(sessionId); - ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); - - ActiveConnectionsRegistry.getInstance().register(ctx); - - // ответ - Net_SessionLogin_Response resp = new Net_SessionLogin_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setStoragePwd(session.getStoragePwd()); - return resp; - } - - private static boolean verifySessionLoginSignature( - String sessionPubKeyB64, - String sessionId, - long timeMs, - String nonce, - String signatureB64 - ) throws IllegalArgumentException { - - // pubKey: Base64(32). (Ed25519Util.keyFromBase64 должен использовать стандартный Base64) - byte[] publicKey32 = Ed25519Util.keyFromBase64(sessionPubKeyB64); - - // signature: Base64(64) через единую утилиту WS-протокола - byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); - - String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce; - byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); - - return Ed25519Util.verify(preimage, signature64, publicKey32); - } -} -package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -public final class Net_AddBlock_Request extends Net_Request { - - private String blockchainName; // обязателен - private int blockNumber; // обязателен - private String prevBlockHash; // HEX(64) или "" для нулевого - private String blockBytesB64; // байты FULL-блока (raw+sig+hash) в Base64 - - public String getBlockchainName() { return blockchainName; } - public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } - - public int getBlockNumber() { return blockNumber; } - public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; } - - public String getPrevBlockHash() { return prevBlockHash; } - public void setPrevBlockHash(String prevBlockHash) { this.prevBlockHash = prevBlockHash; } - - public String getBlockBytesB64() { return blockBytesB64; } - public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; } -} -package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ: - * - reasonCode (null если ok) - * - serverLastGlobalNumber / serverLastGlobalHash - */ -public final class Net_AddBlock_Response extends Net_Response { - - /** null если ok, иначе строка причины (bad_block_base64, user_not_found, и т.п.) */ - private String reasonCode; - - /** что сервер считает последним по глобальной цепочке */ - private int serverLastGlobalNumber; - private String serverLastGlobalHash; - - public String getReasonCode() { return reasonCode; } - public void setReasonCode(String reasonCode) { this.reasonCode = reasonCode; } - - public int getServerLastGlobalNumber() { return serverLastGlobalNumber; } - public void setServerLastGlobalNumber(int v) { this.serverLastGlobalNumber = v; } - - public String getServerLastGlobalHash() { return serverLastGlobalHash; } - public void setServerLastGlobalHash(String v) { this.serverLastGlobalHash = v; } -} -package server.logic.ws_protocol.JSON.handlers.blockchain; - -import blockchain.BchBlockEntry; -import blockchain.BchCryptoVerifier; -import blockchain.MsgSubType; -import blockchain.body.BodyHasLine; -import blockchain.body.BodyHasTarget; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.Base64Ws; -import server.logic.ws_protocol.JSON.ConnectionContext; -import server.logic.ws_protocol.JSON.entyties.Net_Exception_Response; -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.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks; -import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter; -import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; -import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.BlocksDAO; -import shine.db.entities.BlockchainStateEntry; -import shine.db.entities.BlockEntry; -import utils.blockchain.BlockchainNameUtil; - -import java.util.Arrays; -import java.util.concurrent.locks.ReentrantLock; - -/** - * Net_AddBlock_Handler — единый хэндлер добавления блока (JSON). - * - * Изменение (v3): - * - ВСЕ ошибки теперь возвращаются в стандартном формате Net_Exception_Response: - * status != 200, payload: { code, message, serverLastGlobalNumber, serverLastGlobalHash } - * - Успех — как и раньше Net_AddBlock_Response (status=200). - */ -public final class Net_AddBlock_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_AddBlock_Handler.class); - - private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); - private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); - - private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) { - - Net_AddBlock_Request req = (Net_AddBlock_Request) baseReq; - - String blockchainName = req.getBlockchainName(); - ReentrantLock lock = BlockchainLocks.lockFor(blockchainName); - lock.lock(); - try { - AddBlockResult r = addBlock( - blockchainName, - req.getBlockNumber(), // старое поле, пока оставляем - req.getPrevBlockHash(), // старое поле, пока оставляем - req.getBlockBytesB64() - ); - - // ✅ УСПЕХ: как раньше - if (r.isOk()) { - Net_AddBlock_Response resp = new Net_AddBlock_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - resp.setReasonCode(null); - resp.setServerLastGlobalNumber(r.serverLastBlockNumber); - resp.setServerLastGlobalHash(r.serverLastBlockHashHex); - - return resp; - } - - // ✅ ОШИБКА: стандартный формат (code + message) + доп.поля для ресинка - return error(req, r.httpStatus, r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex); - - } finally { - lock.unlock(); - } - } - - private Net_Response error(Net_AddBlock_Request req, - int status, - String reasonCode, - int serverLastNum, - String serverLastHashHex) { - - AddBlockExceptionResponse resp = new AddBlockExceptionResponse(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(status); - - // code — машинный - resp.setCode(reasonCode != null ? reasonCode : "add_block_error"); - // message — человеческий (можешь улучшать тексты как угодно) - resp.setMessage(humanMessage(reasonCode)); - - // полезно клиенту для ресинка - resp.setServerLastGlobalNumber(serverLastNum); - resp.setServerLastGlobalHash(serverLastHashHex); - - return resp; - } - - private static String humanMessage(String code) { - if (code == null) return "Ошибка добавления блока"; - - return switch (code) { - case "empty_blockchain_name" -> "Пустое имя блокчейна"; - case "bad_blockchain_name" -> "Некорректное имя блокчейна"; - case "db_error" -> "Ошибка базы данных"; - case "blockchain_state_not_found" -> "Состояние блокчейна не найдено"; - case "state_last_hash_invalid" -> "Повреждено состояние блокчейна: неверный last_block_hash"; - case "bad_block_base64" -> "Некорректный base64 блока"; - case "limit_exceeded" -> "Превышен лимит размера блокчейна"; - case "limit_check_failed" -> "Ошибка проверки лимита размера"; - case "bad_block_format" -> "Некорректный формат блока"; - case "bad_block_body" -> "Некорректное тело блока"; - case "bad_block_number" -> "Некорректный номер блока"; - case "req_global_mismatch" -> "Номер блока в запросе не совпадает с номером в блоке"; - case "bad_prev_hash" -> "Некорректный prevHash (цепочка не совпадает)"; - case "bad_blockchain_key_len" -> "Некорректный ключ блокчейна в состоянии (ожидалось 32 байта)"; - case "signature_verify_failed" -> "Ошибка проверки подписи блока"; - case "bad_signature" -> "Некорректная подпись блока"; - case "prev_line_block_not_found" -> "Не найден блок prevLineNumber для проверки линии"; - case "bad_prev_line_hash" -> "Некорректный prevLineHash"; - case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine"; - case "internal_error" -> "Внутренняя ошибка сервера при записи блока"; - default -> "Ошибка: " + code; - }; - } - - private AddBlockResult addBlock( - String blockchainName, - int globalNumberFromReq, - String prevGlobalHashHexFromReq, - String blockBytesB64 - ) { - if (blockchainName == null || blockchainName.isBlank()) { - log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "empty_blockchain_name", 0, ""); - } - - String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName); - if (login == null || login.isBlank()) { - log.warn("AddBlock: плохой blockchainName='{}' => login не получился (reqGlobalNumber={})", - blockchainName, globalNumberFromReq); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_name", 0, ""); - } - - // 1) state обязателен - final BlockchainStateEntry st; - try { - st = stateDAO.getByBlockchainName(blockchainName); - } catch (Exception e) { - log.error("AddBlock: ошибка БД при чтении blockchain_state (login={}, blockchainName={}, reqGlobalNumber={})", - login, blockchainName, globalNumberFromReq, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "db_error", 0, ""); - } - - if (st == null) { - log.warn("AddBlock: blockchain_state_not_found (login={}, blockchainName={}, reqGlobalNumber={})", - login, blockchainName, globalNumberFromReq); - return new AddBlockResult(WireCodes.Status.NOT_FOUND, "blockchain_state_not_found", -1, ""); - } - - final int serverLastNum = st.getLastBlockNumber(); - - final byte[] serverLastHash32; - try { - serverLastHash32 = (serverLastNum < 0) - ? new byte[32] - : require32OrThrow(st.getLastBlockHash(), "state.last_block_hash is null/invalid"); - } catch (Exception e) { - // ✅ Раньше тут мог вылететь неожиданный 500 через внешний try/catch. - log.error("AddBlock: state_last_hash_invalid (login={}, blockchainName={}, serverLastNum={})", - login, blockchainName, serverLastNum, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "state_last_hash_invalid", serverLastNum, ""); - } - - final String serverLastHashHex = toHex(serverLastHash32); - - // 2) decode block - final byte[] blockBytes; - try { - blockBytes = decodeBase64(blockBytesB64); - } catch (Exception e) { - log.warn("AddBlock: некорректный base64 блока (login={}, blockchainName={}, reqGlobalNumber={})", - login, blockchainName, globalNumberFromReq, e); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_base64", serverLastNum, serverLastHashHex); - } - - // 3) лимит (оставляем как было) - try { - long oldSize = st.getFileSizeBytes(); - long limit = st.getSizeLimit(); - long newSize = safeAdd(oldSize, blockBytes.length); - - if (limit > 0 && newSize > limit) { - log.warn("AddBlock: limit_exceeded (login={}, blockchainName={}, oldSize={}, addLen={}, newSize={}, limit={})", - login, blockchainName, oldSize, blockBytes.length, newSize, limit); - return new AddBlockResult(413, "limit_exceeded", serverLastNum, serverLastHashHex); - } - } catch (Exception e) { - log.error("AddBlock: limit_check_failed (login={}, blockchainName={})", login, blockchainName, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "limit_check_failed", serverLastNum, serverLastHashHex); - } - - // 4) parse block - final BchBlockEntry block; - try { - block = new BchBlockEntry(blockBytes); - } catch (Exception e) { - log.warn("AddBlock: не удалось распарсить BchBlockEntry (login={}, blockchainName={}, bytesLen={})", - login, blockchainName, blockBytes.length, e); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_format", serverLastNum, serverLastHashHex); - } - - // body.check() - try { - block.body.check(); - } catch (Exception e) { - log.warn("AddBlock: body.check() не прошёл (login={}, blockchainName={}, blockNumber={}, type={}, ver={})", - login, blockchainName, block.blockNumber, (block.type & 0xFFFF), (block.version & 0xFFFF), e); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_body", serverLastNum, serverLastHashHex); - } - - // 4.2) запрет дырок: blockNumber строго last+1 - int expectedBlockNumber = serverLastNum + 1; - if (block.blockNumber != expectedBlockNumber) { - log.warn("AddBlock: bad_block_number (login={}, blockchainName={}, пришёл={}, ожидали={}, serverLastNum={})", - login, blockchainName, block.blockNumber, expectedBlockNumber, serverLastNum); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_number", serverLastNum, serverLastHashHex); - } - - // (временная совместимость) req.globalNumber должен совпасть с block.blockNumber - if (globalNumberFromReq != block.blockNumber) { - log.warn("AddBlock: req_global_mismatch (login={}, blockchainName={}, reqGlobal={}, blockNumber={})", - login, blockchainName, globalNumberFromReq, block.blockNumber); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "req_global_mismatch", serverLastNum, serverLastHashHex); - } - - // 4.3) проверка цепочки по prevHash32 - if (!Arrays.equals(block.prevHash32, serverLastHash32)) { - log.warn("AddBlock: bad_prev_hash (login={}, blockchainName={}, blockNumber={}, clientPrev={}, serverPrev={})", - login, blockchainName, block.blockNumber, toHex(block.prevHash32), serverLastHashHex); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_prev_hash", serverLastNum, serverLastHashHex); - } - - // 5) pubKey - final byte[] pubKey32 = st.getBlockchainKeyBytes(); - if (pubKey32 == null || pubKey32.length != 32) { - log.warn("AddBlock: bad_blockchain_key_len (login={}, blockchainName={}, blockNumber={}, keyLen={})", - login, blockchainName, block.blockNumber, (pubKey32 == null ? -1 : pubKey32.length)); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex); - } - - // 6) подпись по hash32(preimage) - boolean sigOk; - try { - sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32); - } catch (Exception e) { - log.warn("AddBlock: signature_verify_failed (login={}, blockchainName={}, blockNumber={})", - login, blockchainName, block.blockNumber, e); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "signature_verify_failed", serverLastNum, serverLastHashHex); - } - - if (!sigOk) { - log.warn("AddBlock: bad_signature (login={}, blockchainName={}, blockNumber={})", - login, blockchainName, block.blockNumber); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_signature", serverLastNum, serverLastHashHex); - } - - // 7) line columns (only for BodyHasLine) - Integer lineCode = null; - Integer prevLineNumber = null; - byte[] prevLineHash32 = null; - Integer thisLineNumber = null; - - if (block.body instanceof BodyHasLine bl) { - lineCode = bl.lineCode(); - prevLineNumber = bl.prevLineBlockGlobalNumber(); - prevLineHash32 = bl.prevLineBlockHash32(); - thisLineNumber = bl.lineSeq(); - - // Нормализация: -1 не пишем в БД (для совместимости со старым TextBody) - if (prevLineNumber != null && prevLineNumber == -1) { - prevLineNumber = null; - prevLineHash32 = null; - thisLineNumber = null; - } - - // Если prevLineNumber задан — проверяем его хэш - if (prevLineNumber != null) { - try { - byte[] dbPrevHash = blocksDAO.getHashByNumber(blockchainName, prevLineNumber); - if (dbPrevHash == null) { - log.warn("AddBlock: prev_line_block_not_found (login={}, blockchainName={}, blockNumber={}, prevLineNumber={})", - login, blockchainName, block.blockNumber, prevLineNumber); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "prev_line_block_not_found", serverLastNum, serverLastHashHex); - } - if (!Arrays.equals(dbPrevHash, require32OrThrow(prevLineHash32, "prevLineHash32 invalid"))) { - log.warn("AddBlock: bad_prev_line_hash (login={}, blockchainName={}, blockNumber={}, prevLineNumber={})", - login, blockchainName, block.blockNumber, prevLineNumber); - return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_prev_line_hash", serverLastNum, serverLastHashHex); - } - } catch (Exception e) { - log.error("AddBlock: db_error_prev_line_check (login={}, blockchainName={}, blockNumber={})", - login, blockchainName, block.blockNumber, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "db_error_prev_line_check", serverLastNum, serverLastHashHex); - } - } - } - - // 8) сформировать запись и записать (DB + state + файл) - try { - BlockEntry be = new BlockEntry(); - be.setLogin(login); - be.setBchName(blockchainName); - - be.setBlockNumber(block.blockNumber); - be.setMsgType(block.type & 0xFFFF); - be.setMsgSubType(block.subType & 0xFFFF); - - be.setBlockBytes(block.toBytes()); - be.setBlockHash(block.getHash32()); - be.setBlockSignature(block.getSignature64()); - - // line columns (optional) - be.setLineCode(lineCode); - be.setPrevLineNumber(prevLineNumber); - be.setPrevLineHash(prevLineHash32); - be.setThisLineNumber(thisLineNumber); - - // target columns (optional) - if (block.body instanceof BodyHasTarget t) { - be.setToLogin(t.toLogin()); - be.setToBchName(t.toBchName()); - be.setToBlockNumber(t.toBlockGlobalNumber()); - be.setToBlockHash(t.toBlockHashBytes()); - } - - // edit helper (optional): если TEXT_EDIT_* — это "редактирование блока цели" - int type = block.type & 0xFFFF; - int sub = block.subType & 0xFFFF; - - if (type == 1 - && (sub == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || sub == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) - && be.getToBlockNumber() != null) { - be.setEditedByBlockNumber(be.getToBlockNumber()); - } - - dbWriter.appendBlockAndState(blockchainName, block, st, be); - - } catch (Exception e) { - log.error("AddBlock: внутренняя ошибка при записи блока (login={}, blockchainName={}, blockNumber={})", - login, blockchainName, block.blockNumber, e); - return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex); - } - - String newHashHex = toHex(block.getHash32()); - - log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}", - login, blockchainName, block.blockNumber, newHashHex); - - return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex); - } - - /* ===================================================================== */ - /* ====================== Helpers ====================================== */ - /* ===================================================================== */ - - private static byte[] decodeBase64(String b64) { - if (b64 == null) throw new IllegalArgumentException("blockBytesB64 == null"); - return Base64Ws.decode(b64); - } - - private static long safeAdd(long a, long b) { - long r = a + b; - if (((a ^ r) & (b ^ r)) < 0) throw new ArithmeticException("long overflow"); - return r; - } - - private static byte[] require32OrThrow(byte[] b, String msg) { - if (b == null || b.length != 32) throw new IllegalArgumentException(msg); - return b; - } - - private static String toHex(byte[] bytes) { - if (bytes == null) return "null"; - char[] HEX = "0123456789abcdef".toCharArray(); - char[] out = new char[bytes.length * 2]; - for (int i = 0; i < bytes.length; i++) { - int v = bytes[i] & 0xFF; - out[i * 2] = HEX[v >>> 4]; - out[i * 2 + 1] = HEX[v & 0x0F]; - } - return new String(out); - } - - /** - * Спец-ответ ошибки AddBlock: стандартный code/message + поля для ресинка. - * В wire-формате это окажется внутри payload. - */ - public static final class AddBlockExceptionResponse extends Net_Exception_Response { - private Integer serverLastGlobalNumber; - private String serverLastGlobalHash; - - public Integer getServerLastGlobalNumber() { - return serverLastGlobalNumber; - } - - public void setServerLastGlobalNumber(Integer serverLastGlobalNumber) { - this.serverLastGlobalNumber = serverLastGlobalNumber; - } - - public String getServerLastGlobalHash() { - return serverLastGlobalHash; - } - - public void setServerLastGlobalHash(String serverLastGlobalHash) { - this.serverLastGlobalHash = serverLastGlobalHash; - } - } - - private static final class AddBlockResult { - final int httpStatus; - final String reasonCode; - final int serverLastBlockNumber; - final String serverLastBlockHashHex; - - AddBlockResult(int httpStatus, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) { - this.httpStatus = httpStatus; - this.reasonCode = reasonCode; - this.serverLastBlockNumber = serverLastBlockNumber; - this.serverLastBlockHashHex = serverLastBlockHashHex; - } - - boolean isOk() { return httpStatus == WireCodes.Status.OK; } - } -} - -package server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils; - -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.ReentrantLock; - -public final class BlockchainLocks { - private static final ConcurrentHashMap MAP = new ConcurrentHashMap<>(); - - private BlockchainLocks() {} - - public static ReentrantLock lockFor(String blockchainName) { - return MAP.computeIfAbsent(blockchainName, id -> new ReentrantLock(true)); // fair=true - } -} -package server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils; - -import blockchain.BchBlockEntry; -import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.BlocksDAO; -import shine.db.entities.BlockchainStateEntry; -import shine.db.entities.BlockEntry; -import utils.files.FileStoreUtil; - -import java.sql.Connection; -import java.sql.SQLException; - -/** - * BlockchainWriter — запись блока в DB + обновление state + запись в файл. - * - * ВАЖНО: - * - Это минимальный рабочий вариант под новый формат. - * - Если у тебя уже есть "атомарность" сложнее (tmp_bch + commit/recovery) — можно усилить потом. - */ -public final class BlockchainWriter { - - private final BlocksDAO blocksDAO; - private final BlockchainStateDAO stateDAO; - private final FileStoreUtil fs = FileStoreUtil.getInstance(); - - public BlockchainWriter(BlocksDAO blocksDAO, BlockchainStateDAO stateDAO) { - this.blocksDAO = blocksDAO; - this.stateDAO = stateDAO; - } - - public void appendBlockAndState(String blockchainName, - BchBlockEntry block, - BlockchainStateEntry st, - BlockEntry be) throws SQLException { - - long nowMs = System.currentTimeMillis(); - - try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) { - c.setAutoCommit(false); - try { - // 1) insert block - blocksDAO.insert(c, be); - - // 2) update state - st.setLastBlockNumber(block.blockNumber); - st.setLastBlockHash(block.getHash32()); - st.setFileSizeBytes(st.getFileSizeBytes() + block.toBytes().length); - st.setUpdatedAtMs(nowMs); - - stateDAO.upsert(c, st); - - c.commit(); - } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - if (e instanceof SQLException se) throw se; - throw new SQLException("appendBlockAndState failed", e); - } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} - } - } - - // 3) append to file (минимально: просто дописать) - // Если у тебя уже есть логика tmp_bch+atomicReplace — можно заменить тут. - String fileName = fs.buildBlockchainFileName(blockchainName); - fs.addDataToFile(fileName, block.toBytes()); - } -} -package server.logic.ws_protocol.JSON.handlers.connections.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос GetFriendsLists — получить два списка "друзей" по connections_state. - * - * { - * "op": "GetFriendsLists", - * "requestId": "req-100", - * "payload": { - * "login": "anya" - * } - * } - * - * Возвращает: - * - out_friends: кому login поставил FRIEND - * - in_friends: кто поставил FRIEND этому login - * - * ПРО ДОСТУП (на будущее): - * Сейчас (MVP) без ограничений. Позже можно ограничить видимость связей. - */ -public class Net_GetFriendsLists_Request extends Net_Request { - - private String login; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } -} -package server.logic.ws_protocol.JSON.handlers.connections.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.ArrayList; -import java.util.List; - -/** - * Ответ GetFriendsLists. - * - * { - * "op": "GetFriendsLists", - * "requestId": "req-100", - * "status": 200, - * "payload": { - * "login": "Anya", // канонический регистр из БД - * "out_friends": ["Bob", "Kate"], // кому login поставил FRIEND - * "in_friends": ["Alex", "Kate"] // кто поставил FRIEND login - * } - * } - */ -public class Net_GetFriendsLists_Response extends Net_Response { - - private String login; - - private List out_friends = new ArrayList<>(); - private List in_friends = new ArrayList<>(); - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public List getOut_friends() { return out_friends; } - public void setOut_friends(List out_friends) { this.out_friends = out_friends; } - - public List getIn_friends() { return in_friends; } - public void setIn_friends(List in_friends) { this.in_friends = in_friends; } -} -package server.logic.ws_protocol.JSON.handlers.connections; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.connections.entyties.Net_GetFriendsLists_Request; -import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.MsgSubType; -import shine.db.SqliteDbController; -import shine.db.dao.ConnectionsStateDAO; - -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.util.List; - -/** - * GetFriendsLists — получить 2 списка: - * - out_friends: кому login поставил FRIEND - * - in_friends: кто поставил FRIEND этому login - * - * ВАЖНО: - * - login в запросе может быть любым регистром - * - в ответе возвращаем канонический регистр (как в solana_users.login) - * - * ПРИМЕЧАНИЕ: - * Таблица пользователей тут названа "solana_users". Если у тебя иначе — поменяй SQL. - */ -public class Net_GetFriendsLists_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_GetFriendsLists_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_GetFriendsLists_Request req = (Net_GetFriendsLists_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login" - ); - } - - final String loginAnyCase = req.getLogin().trim(); - - try { - SqliteDbController db = SqliteDbController.getInstance(); - ConnectionsStateDAO dao = ConnectionsStateDAO.getInstance(); - - try (Connection c = db.getConnection()) { - - // 1) Канонизируем login через solana_users (NOCASE) - String canonicalLogin = findCanonicalLogin(c, loginAnyCase); - if (canonicalLogin == null) { - return NetExceptionResponseFactory.error( - req, - 404, - "USER_NOT_FOUND", - "Пользователь не найден" - ); - } - - int relType = (int) MsgSubType.CONNECTION_FRIEND; - - // 2) Два списка (логины канонические) - List outFriends = dao.listOutgoingByRelTypeCanonical(c, canonicalLogin, relType); - List inFriends = dao.listIncomingByRelTypeCanonical(c, canonicalLogin, relType); - - Net_GetFriendsLists_Response resp = new Net_GetFriendsLists_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - resp.setLogin(canonicalLogin); - resp.setOut_friends(outFriends); - resp.setIn_friends(inFriends); - - return resp; - } - - } catch (Exception e) { - log.error("❌ Internal error GetFriendsLists", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } - - private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception { - String sql = """ - SELECT login - FROM solana_users - WHERE login = ? COLLATE NOCASE - LIMIT 1 - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, loginAnyCase); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return rs.getString("login"); - } - } - } -} -package server.logic.ws_protocol.JSON.handlers; - -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; - -/** - * Общий интерфейс для всех JSON-хэндлеров. - */ -public interface JsonMessageHandler { - - /** - * Обработать запрос и вернуть ответ. - * - * @param request распарсенный запрос - * @param ctx контекст текущего WebSocket-соединения - */ - Net_Response handle(Net_Request request, ConnectionContext ctx) throws Exception; -} - -package server.logic.ws_protocol.JSON.handlers.system.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Ping: - * { - * "op": "Ping", - * "requestId": "req-1", - * "payload": { "ts": 1700000000000 } - * } - * - * Сервер ничего не проверяет, поле ts можно слать любое. - */ -public class Net_Ping_Request extends Net_Request { - - private long ts; - - public long getTs() { return ts; } - public void setTs(long ts) { this.ts = ts; } -} -package server.logic.ws_protocol.JSON.handlers.system.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Pong-ответ: - * { - * "op": "Ping", - * "requestId": "req-1", - * "status": 200, - * "payload": { "ts": 1700000000123 } - * } - */ -public class Net_Ping_Response extends Net_Response { - - private long ts; - - public long getTs() { return ts; } - public void setTs(long ts) { this.ts = ts; } -} -package server.logic.ws_protocol.JSON.handlers.system; - -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.system.entyties.Net_Ping_Request; -import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Response; -import server.logic.ws_protocol.WireCodes; - -/** - * Ping — keep-alive. - * В ответ кладём только ts (текущее время сервера в мс). - */ -public class Net_Ping_Handler implements JsonMessageHandler { - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_Ping_Request req = (Net_Ping_Request) baseRequest; - - Net_Ping_Response resp = new Net_Ping_Response(); - resp.setOp(req.getOp()); // "Ping" - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - // ничего не проверяем, просто отдаём серверное время - resp.setTs(System.currentTimeMillis()); - - return resp; - } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос AddUser — временная/тестовая регистрация локального пользователя. - * - * Клиент отправляет: - * - * { - * "op": "AddUser", - * "requestId": "test-add-1", - * "payload": { - * "login": "anya", - * "blockchainName": "anya-001", - * "solanaKey": "base64-ed25519-public-key-login", - * "blockchainKey": "base64-ed25519-public-key-blockchain", - * "deviceKey": "base64-ed25519-public-key-device", - * "bchLimit": 1000000 - * } - * } - * - * Все поля лежат внутри payload. - */ -public class Net_AddUser_Request extends Net_Request { - - private String login; - private String blockchainName; - - /** Ключ пользователя Solana (публичный ключ логина) */ - private String solanaKey; - - /** Ключ блокчейна (публичный ключ блокчейна) */ - private String blockchainKey; - - /** Ключ устройства (публичный ключ устройства) */ - private String deviceKey; - - private Integer bchLimit; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getBlockchainName() { return blockchainName; } - public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } - - public String getSolanaKey() { return solanaKey; } - public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; } - - public String getBlockchainKey() { return blockchainKey; } - public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } - - public String getDeviceKey() { return deviceKey; } - public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } - - public Integer getBchLimit() { return bchLimit; } - public void setBchLimit(Integer bchLimit) { this.bchLimit = bchLimit; } -} -// file: server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Response.java -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Успешный ответ на AddUser. - * - * Сейчас дополнительных полей нет — достаточно status=200. - * - * Пример: - * { - * "op": "AddUser", - * "requestId": "test-add-1", - * "status": 200, - * "payload": { } - * } - */ -public class Net_AddUser_Response extends Net_Response { - // При необходимости сюда можно добавить, например, флаг created/updated и т.п. -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос GetUser — проверка/получение пользователя по login. - * - * Клиент отправляет: - * - * { - * "op": "GetUser", - * "requestId": "u-1", - * "payload": { - * "login": "AnYa" - * } - * } - * - * Поиск по login выполняется без учёта регистра. - * В ответе возвращаем login/blockchainName с тем регистром, как в БД. - */ -public class Net_GetUser_Request extends Net_Request { - - private String login; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ GetUser. - * - * Всегда status=200. - * - * Пример (нет пользователя): - * { - * "op": "GetUser", - * "requestId": "u-1", - * "status": 200, - * "payload": { "exists": false } - * } - * - * Пример (есть пользователь): - * { - * "op": "GetUser", - * "requestId": "u-1", - * "status": 200, - * "payload": { - * "exists": true, - * "login": "Anya", - * "blockchainName": "anya-001", - * "solanaKey": "...", - * "blockchainKey": "...", - * "deviceKey": "..." - * } - * } - */ -public class Net_GetUser_Response extends Net_Response { - - private Boolean exists; - - private String login; - private String blockchainName; - private String solanaKey; - private String blockchainKey; - private String deviceKey; - - public Boolean getExists() { return exists; } - public void setExists(Boolean exists) { this.exists = exists; } - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getBlockchainName() { return blockchainName; } - public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } - - public String getSolanaKey() { return solanaKey; } - public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; } - - public String getBlockchainKey() { return blockchainKey; } - public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } - - public String getDeviceKey() { return deviceKey; } - public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос SearchUsers — поиск логинов по префиксу. - * - * Клиент отправляет: - * { - * "op": "SearchUsers", - * "requestId": "su-1", - * "payload": { "prefix": "any" } - * } - * - * Поиск по prefix выполняется без учёта регистра. - * В ответе возвращаем логины с тем регистром, как в БД. - */ -public class Net_SearchUsers_Request extends Net_Request { - - private String prefix; - - public String getPrefix() { return prefix; } - public void setPrefix(String prefix) { this.prefix = prefix; } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.ArrayList; -import java.util.List; - -/** - * Ответ SearchUsers. - * - * Всегда status=200. - * - * Пример: - * { - * "op": "SearchUsers", - * "requestId": "su-1", - * "status": 200, - * "payload": { - * "logins": ["Anya", "andrew", "Angel"] - * } - * } - */ -public class Net_SearchUsers_Response extends Net_Response { - - private List logins = new ArrayList<>(); - - public List getLogins() { return logins; } - public void setLogins(List logins) { this.logins = logins; } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.Base64Ws; -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.tempToTest.entyties.Net_AddUser_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.BlockchainStateEntry; -import shine.db.entities.SolanaUserEntry; -import utils.blockchain.BlockchainNameUtil; - -import java.sql.Connection; -import java.sql.SQLException; - -public class Net_AddUser_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_AddUser_Handler.class); - - /** TEST ONLY */ - private static final int TEST_BCH_LIMIT = 1_000_000; - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_AddUser_Request req = (Net_AddUser_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank() - || req.getBlockchainName() == null || req.getBlockchainName().isBlank() - || req.getSolanaKey() == null || req.getSolanaKey().isBlank() - || req.getBlockchainKey() == null || req.getBlockchainKey().isBlank() - || req.getDeviceKey() == null || req.getDeviceKey().isBlank()) { - - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login/blockchainName/solanaKey/blockchainKey/deviceKey" - ); - } - - // blockchainName должен быть вида: -NNN - if (!BlockchainNameUtil.isBlockchainNameMatchesLogin(req.getBlockchainName(), req.getLogin())) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BLOCKCHAIN_NAME", - "blockchainName должен быть вида -NNN (пример: anya-001)" - ); - } - - int limit = (req.getBchLimit() == null || req.getBchLimit() <= 0) - ? TEST_BCH_LIMIT - : req.getBchLimit(); - - try { - // базовая валидация форматов ключей: Base64(32 bytes) - byte[] solanaKey32; - byte[] blockchainKey32; - byte[] deviceKey32; - - try { - solanaKey32 = Base64Ws.decodeLen(req.getSolanaKey(), 32, "solanaKey"); - blockchainKey32 = Base64Ws.decodeLen(req.getBlockchainKey(), 32, "blockchainKey"); - deviceKey32 = Base64Ws.decodeLen(req.getDeviceKey(), 32, "deviceKey"); - } catch (IllegalArgumentException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_KEY_FORMAT", - e.getMessage() - ); - } - - // (переменные не используются дальше, но оставляем для ясности проверки длины) - if (solanaKey32.length != 32 || blockchainKey32.length != 32 || deviceKey32.length != 32) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_KEY_FORMAT", - "solanaKey/blockchainKey/deviceKey должны быть Base64(32 bytes)" - ); - } - - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); - BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); - - SqliteDbController db = SqliteDbController.getInstance(); - - try (Connection c = db.getConnection()) { - c.setAutoCommit(false); - - // 1. Проверяем, что пользователя нет (case-insensitive) - if (usersDAO.getByLogin(c, req.getLogin()) != null) { - return NetExceptionResponseFactory.error( - req, - 409, - "USER_ALREADY_EXISTS", - "Пользователь с таким login уже существует" - ); - } - - // 2. Проверяем, что blockchainName ещё нет (case-sensitive, как в БД) - if (usersDAO.existsByBlockchainName(c, req.getBlockchainName())) { - return NetExceptionResponseFactory.error( - req, - 409, - "BLOCKCHAIN_ALREADY_EXISTS", - "Пользователь с таким blockchainName уже существует" - ); - } - - // 3. На всякий случай оставляем старую проверку blockchain_state, - // потому что эта таблица нужна серверу (состояние цепочки/лимиты). - if (stateDAO.getByBlockchainName(c, req.getBlockchainName()) != null) { - return NetExceptionResponseFactory.error( - req, - 409, - "BLOCKCHAIN_STATE_ALREADY_EXISTS", - "blockchain_state уже существует" - ); - } - - // 4. Создаём пользователя (все поля теперь лежат в solana_users) - SolanaUserEntry user = new SolanaUserEntry(); - user.setLogin(req.getLogin()); - user.setBlockchainName(req.getBlockchainName()); - user.setSolanaKey(req.getSolanaKey()); - user.setBlockchainKey(req.getBlockchainKey()); - user.setDeviceKey(req.getDeviceKey()); - - usersDAO.insert(c, user); - - // 5. Создаём INITIAL blockchain_state (для работы сервера) - BlockchainStateEntry st = new BlockchainStateEntry(); - st.setBlockchainName(req.getBlockchainName()); - st.setLogin(req.getLogin()); - st.setBlockchainKey(req.getBlockchainKey()); // Base64(32) - st.setLastBlockNumber(-1); - st.setLastBlockHash(new byte[32]); - st.setFileSizeBytes(0); - st.setSizeLimit(limit); - st.setUpdatedAtMs(System.currentTimeMillis()); - - stateDAO.upsert(c, st); - - c.commit(); - } - - Net_AddUser_Response resp = new Net_AddUser_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - log.info("✅ AddUser ok: login={}, blockchainName={}, limit={}", - req.getLogin(), req.getBlockchainName(), limit); - - return resp; - - } catch (SQLException e) { - log.error("❌ DB error AddUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error AddUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.tempToTest.entyties.Net_GetUser_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; - -import java.sql.SQLException; - -public class Net_GetUser_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_GetUser_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_GetUser_Request req = (Net_GetUser_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank()) { - // тут логичнее BAD_REQUEST, но ты просил: "нет пользователя" тоже 200. - // Поэтому BAD_REQUEST оставляем только на реально пустой login. - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login" - ); - } - - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); - - try { - SolanaUserEntry u = usersDAO.getByLogin(req.getLogin()); - - Net_GetUser_Response resp = new Net_GetUser_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - if (u == null) { - resp.setExists(false); - log.info("ℹ️ GetUser: not found for login={}", req.getLogin()); - return resp; - } - - // ВАЖНО: - // - Поиск по login был case-insensitive, - // - а тут возвращаем login/blockchainName как в БД (с исходным регистром). - resp.setExists(true); - resp.setLogin(u.getLogin()); - resp.setBlockchainName(u.getBlockchainName()); - resp.setSolanaKey(u.getSolanaKey()); - resp.setBlockchainKey(u.getBlockchainKey()); - resp.setDeviceKey(u.getDeviceKey()); - - log.info("✅ GetUser: found login={}, blockchainName={}", u.getLogin(), u.getBlockchainName()); - return resp; - - } catch (SQLException e) { - log.error("❌ DB error GetUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error GetUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.tempToTest.entyties.Net_SearchUsers_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_SearchUsers_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; - -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.List; - -public class Net_SearchUsers_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_SearchUsers_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_SearchUsers_Request req = (Net_SearchUsers_Request) baseRequest; - - if (req.getPrefix() == null || req.getPrefix().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: prefix" - ); - } - - String prefix = req.getPrefix().trim(); - - try { - SolanaUsersDAO dao = SolanaUsersDAO.getInstance(); - List users = dao.searchByLoginPrefix(prefix); // case-insensitive + LIMIT 5 - - List logins = new ArrayList<>(); - for (SolanaUserEntry u : users) { - if (u != null && u.getLogin() != null) { - logins.add(u.getLogin()); // регистр как в БД - } - } - - Net_SearchUsers_Response resp = new Net_SearchUsers_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setLogins(logins); - - log.info("✅ SearchUsers ok: prefix='{}' -> {}", prefix, logins.size()); - return resp; - - } catch (SQLException e) { - log.error("❌ DB error SearchUsers", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error SearchUsers", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос GetUserParam — получить один параметр пользователя. - * - * { - * "op": "GetUserParam", - * "requestId": "req-1", - * "payload": { - * "login": "anya", - * "param": "feed:lastSeenGlobal" - * } - * } - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) этот запрос не ограничивает просмотр параметров, т.к. проект в тестовом режиме. - * Позже, вероятно, потребуется ограничить: кто и какие параметры может читать (сессия/права). - * Но для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_GetUserParam_Request extends Net_Request { - - private String login; - private String param; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ GetUserParam. - * - * Если найден: - * { - * "op": "GetUserParam", - * "requestId": "req-1", - * "status": 200, - * "payload": { - * "login": "anya", - * "param": "feed:lastSeenGlobal", - * "time_ms": 1736000000123, - * "value": "105", - * "device_key": "base64-32", - * "signature": "base64-64" - * } - * } - * - * Если не найден: - * status=404, payload пустой. - */ -public class Net_GetUserParam_Response extends Net_Response { - - private String login; - private String param; - private Long time_ms; - private String value; - private String device_key; - private String signature; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } - - public Long getTime_ms() { return time_ms; } - public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } - - public String getValue() { return value; } - public void setValue(String value) { this.value = value; } - - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос ListUserParams — получить все сохранённые параметры пользователя. - * - * { - * "op": "ListUserParams", - * "requestId": "req-2", - * "payload": { - * "login": "anya" - * } - * } - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) запрос не ограничивает просмотр параметров. - * В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры. - * Для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_ListUserParams_Request extends Net_Request { - - private String login; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.ArrayList; -import java.util.List; - -/** - * Ответ ListUserParams — список всех параметров пользователя. - * - * { - * "op": "ListUserParams", - * "requestId": "req-2", - * "status": 200, - * "payload": { - * "login": "anya", - * "params": [ - * { - * "login": "anya", - * "param": "feed:lastSeenGlobal", - * "time_ms": 1736000000123, - * "value": "105", - * "device_key": "base64-32", - * "signature": "base64-64" - * }, - * ... - * ] - * } - * } - */ -public class Net_ListUserParams_Response extends Net_Response { - - private String login; - private List params = new ArrayList<>(); - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public List getParams() { return params; } - public void setParams(List params) { this.params = params; } - - public static class Item { - private String login; - private String param; - private Long time_ms; - private String value; - private String device_key; - private String signature; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } - - public Long getTime_ms() { return time_ms; } - public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } - - public String getValue() { return value; } - public void setValue(String value) { this.value = value; } - - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос UpsertUserParam — добавить/обновить сохранённый параметр пользователя. - * - * Клиент отправляет: - * - * { - * "op": "UpsertUserParam", - * "requestId": "req-123", - * "payload": { - * "login": "anya", - * "param": "feed:lastSeenGlobal", - * "time_ms": 1736000000123, - * "value": "105", - * "device_key": "base64-ed25519-public-key-32", - * "signature": "base64-ed25519-signature-64" - * } - * } - * - * Подпись считается от UTF-8 строки: - * USER_PARAMETER_PREFIX + login + param + time_ms + value - */ -public class Net_UpsertUserParam_Request extends Net_Request { - - private String login; - private String param; - private Long time_ms; - private String value; - - private String device_key; - private String signature; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } - - public Long getTime_ms() { return time_ms; } - public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } - - public String getValue() { return value; } - public void setValue(String value) { this.value = value; } - - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на UpsertUserParam. - * - * Успех: - * { - * "op": "UpsertUserParam", - * "requestId": "req-123", - * "status": 200, - * "payload": { } - * } - */ -public class Net_UpsertUserParam_Response extends Net_Response { - // MVP: без payload. При желании позже можно добавить created/updated. -} -package server.logic.ws_protocol.JSON.handlers.userParams; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.userParams.entyties.Net_GetUserParam_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.UserParamsDAO; -import shine.db.entities.UserParamEntry; - -import java.sql.Connection; - -/** - * GetUserParam — получить один параметр пользователя. - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) запрос не ограничивает просмотр параметров. - * В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры. - * Для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_GetUserParam_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_GetUserParam_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_GetUserParam_Request req = (Net_GetUserParam_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank() - || req.getParam() == null || req.getParam().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login/param" - ); - } - - String login = req.getLogin().trim(); - String param = req.getParam().trim(); - - try { - SqliteDbController db = SqliteDbController.getInstance(); - UserParamsDAO dao = UserParamsDAO.getInstance(); - - try (Connection c = db.getConnection()) { - UserParamEntry e = dao.getByLoginAndParam(c, login, param); - - if (e == null) { - Net_GetUserParam_Response resp = new Net_GetUserParam_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(404); - return resp; - } - - Net_GetUserParam_Response resp = new Net_GetUserParam_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - resp.setLogin(e.getLogin()); - resp.setParam(e.getParam()); - resp.setTime_ms(e.getTimeMs()); - resp.setValue(e.getValue()); - resp.setDevice_key(e.getDeviceKey()); - resp.setSignature(e.getSignature()); - - return resp; - } - - } catch (Exception e) { - log.error("❌ Internal error GetUserParam", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.userParams.entyties.Net_ListUserParams_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.UserParamsDAO; -import shine.db.entities.UserParamEntry; - -import java.sql.Connection; -import java.util.ArrayList; -import java.util.List; - -/** - * ListUserParams — получить все параметры пользователя. - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) запрос не ограничивает просмотр параметров. - * В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры. - * Для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_ListUserParams_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_ListUserParams_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_ListUserParams_Request req = (Net_ListUserParams_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login" - ); - } - - String login = req.getLogin().trim(); - - try { - SqliteDbController db = SqliteDbController.getInstance(); - UserParamsDAO dao = UserParamsDAO.getInstance(); - - List entries; - try (Connection c = db.getConnection()) { - entries = dao.getByLogin(c, login); - } - - Net_ListUserParams_Response resp = new Net_ListUserParams_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - resp.setLogin(login); - - List items = new ArrayList<>(); - for (UserParamEntry e : entries) { - Net_ListUserParams_Response.Item it = new Net_ListUserParams_Response.Item(); - it.setLogin(e.getLogin()); - it.setParam(e.getParam()); - it.setTime_ms(e.getTimeMs()); - it.setValue(e.getValue()); - it.setDevice_key(e.getDeviceKey()); - it.setSignature(e.getSignature()); - items.add(it); - } - resp.setParams(items); - - return resp; - - } catch (Exception e) { - log.error("❌ Internal error ListUserParams", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.Base64Ws; -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.userParams.entyties.Net_UpsertUserParam_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.SolanaUsersDAO; -import shine.db.dao.UserParamsDAO; -import shine.db.entities.SolanaUserEntry; -import shine.db.entities.UserParamEntry; -import utils.config.ShineSignatureConstants; -import utils.crypto.Ed25519Util; - -import java.nio.charset.StandardCharsets; -import java.sql.Connection; -import java.sql.SQLException; - -/** - * Net_UpsertUserParam_Handler - * - * Делает (MVP, без "сессий"): - * 1) Проверка входных полей. - * 2) Проверка подписи Ed25519 по device_key. - * 3) Проверка, что пользователь существует и что device_key принадлежит этому login. - * 4) Атомарная запись в БД "только если time_ms новее" (UPSERT + WHERE). - * - * ВАЖНО: - * - НИКАКИХ ручных транзакций / BEGIN здесь нет. - * - autoCommit=true, каждый statement завершённый сам по себе. - * - Гонки не страшны: если за время проверок кто-то записал более новый time_ms, - * наш финальный UPSERT просто вернёт 0 обновлённых строк. - */ -public class Net_UpsertUserParam_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_UpsertUserParam_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_UpsertUserParam_Request req = (Net_UpsertUserParam_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank() - || req.getParam() == null || req.getParam().isBlank() - || req.getTime_ms() == null || req.getTime_ms() <= 0 - || req.getValue() == null - || req.getDevice_key() == null || req.getDevice_key().isBlank() - || req.getSignature() == null || req.getSignature().isBlank()) { - - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login/param/time_ms/value/device_key/signature" - ); - } - - final String login = req.getLogin().trim(); - final String param = req.getParam().trim(); - final long timeMs = req.getTime_ms(); - final String value = req.getValue(); - final String deviceKeyB64 = req.getDevice_key().trim(); - final String signatureB64 = req.getSignature().trim(); - - try { - // ---------------- Base64 decode ---------------- - byte[] pubKey32; - byte[] sig64; - try { - pubKey32 = Base64Ws.decodeLen(deviceKeyB64, 32, "device_key"); - sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature"); - } catch (IllegalArgumentException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "device_key/signature должны быть Base64" - ); - } - - // ---------------- Signature verify ---------------- - String signText = ShineSignatureConstants.USER_PARAMETER_PREFIX - + login - + param - + timeMs - + value; - - byte[] signBytes = signText.getBytes(StandardCharsets.UTF_8); - - boolean sigOk = Ed25519Util.verify(signBytes, sig64, pubKey32); - if (!sigOk) { - return NetExceptionResponseFactory.error( - req, - 403, - "SIGNATURE_INVALID", - "Подпись не прошла проверку" - ); - } - - // ---------------- DB checks + upsert ---------------- - SqliteDbController db = SqliteDbController.getInstance(); - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); - UserParamsDAO paramsDAO = UserParamsDAO.getInstance(); - - try (Connection c = db.getConnection()) { - // 1) user exists - SolanaUserEntry user = usersDAO.getByLogin(c, login); - if (user == null) { - return NetExceptionResponseFactory.error( - req, - 404, - "USER_NOT_FOUND", - "Пользователь не найден" - ); - } - - // 2) device key must match the user's stored deviceKey - String userDeviceKey = user.getDeviceKey(); - if (userDeviceKey == null || userDeviceKey.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "USER_DEVICE_KEY_EMPTY", - "У пользователя не задан deviceKey в БД" - ); - } - - if (!userDeviceKey.trim().equals(deviceKeyB64)) { - return NetExceptionResponseFactory.error( - req, - 403, - "DEVICE_KEY_MISMATCH", - "device_key не соответствует пользователю" - ); - } - - // 3) atomic upsert-if-newer - UserParamEntry e = new UserParamEntry( - login, - param, - timeMs, - value, - deviceKeyB64, - signatureB64 - ); - - int changed = paramsDAO.upsertIfNewer(c, e); - - Net_UpsertUserParam_Response resp = new Net_UpsertUserParam_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - if (changed == 1) { - log.info("✅ UpsertUserParam applied: login={}, param={}, time_ms={}", login, param, timeMs); - } else { - // 0 строк — значит в БД уже есть time_ms >= incoming - log.info("ℹ️ UpsertUserParam ignored (not newer): login={}, param={}, time_ms={}", login, param, timeMs); - } - - return resp; - } - - } catch (SQLException e) { - log.error("❌ DB error UpsertUserParam", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error UpsertUserParam", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java index 1aca1a9..3b956b8 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_AuthChallenge_Handler.java @@ -20,9 +20,9 @@ import java.security.SecureRandom; * AuthChallenge (v2) — шаг 1 создания новой сессии. * * Логика авторизации (v2): - * - Создание новой сессии возможно ТОЛЬКО через deviceKey пользователя. + * - Создание новой сессии возможно ТОЛЬКО через clientKey пользователя. * - Этот handler выдаёт одноразовый authNonce, который клиент использует во втором шаге: - * CreateAuthSession(..., signature(deviceKey, AUTH_CREATE_SESSION:...)) + * CreateAuthSession(..., signature(clientKey, AUTH_CREATE_SESSION:...)) * * Что делает: * 1) Проверяет login. diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java index be89190..4086c0e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java @@ -30,7 +30,7 @@ import java.security.SecureRandom; import java.sql.SQLException; /** - * CreateAuthSession (v2) — шаг 2 создания новой сессии (ТОЛЬКО deviceKey). + * CreateAuthSession (v2) — шаг 2 создания новой сессии (ТОЛЬКО clientKey). * * Логика авторизации (v2): * - Создание сессии: AuthChallenge(login) -> authNonce -> CreateAuthSession(...) @@ -38,7 +38,7 @@ import java.sql.SQLException; * отправляет на сервер sessionKey целиком одной строкой. * - Сервер сохраняет sessionKey в active_sessions.session_key как есть. * - * Подпись deviceKey (Ed25519) проверяется над строкой (UTF-8): + * Подпись clientKey (Ed25519) проверяется над строкой (UTF-8): * AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} * * На выходе: @@ -226,15 +226,15 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { } String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getClientPlatform()); - String deviceKeyFromDb = user.getDeviceKey(); - if (deviceKeyFromDb == null || deviceKeyFromDb.isBlank()) { + String clientKeyFromDb = user.getClientKey(); + if (clientKeyFromDb == null || clientKeyFromDb.isBlank()) { Net_Response err = NetExceptionResponseFactory.error( req, WireCodes.Status.BAD_REQUEST, "NO_DEVICE_KEY", - "Отсутствует deviceKey у пользователя" + "Отсутствует clientKey у пользователя" ); - closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: no deviceKey"); + closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: no clientKey"); return err; } @@ -261,28 +261,28 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { return err; } - String deviceKeyFromReq = req.getDeviceKey(); - if (deviceKeyFromReq == null || deviceKeyFromReq.isBlank()) { + String clientKeyFromReq = req.getClientKey(); + if (clientKeyFromReq == null || clientKeyFromReq.isBlank()) { Net_Response err = NetExceptionResponseFactory.error( req, WireCodes.Status.BAD_REQUEST, "EMPTY_DEVICE_KEY", - "Пустой deviceKey" + "Пустой clientKey" ); - closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: empty deviceKey"); + closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: empty clientKey"); return err; } - deviceKeyFromReq = deviceKeyFromReq.trim(); + clientKeyFromReq = clientKeyFromReq.trim(); - // TODO: для ротации device_key стоит дополнительно сверять актуальное значение через Solana. - if (!deviceKeyFromReq.equals(deviceKeyFromDb)) { + // TODO: для ротации client_key стоит дополнительно сверять актуальное значение через Solana. + if (!clientKeyFromReq.equals(clientKeyFromDb)) { Net_Response err = NetExceptionResponseFactory.error( req, WireCodes.Status.UNVERIFIED, "DEVICE_KEY_NOT_ACTUAL", - "device_key не соответствует актуальной версии" + "client_key не соответствует актуальной версии" ); - closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: device key mismatch"); + closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: client key mismatch"); return err; } @@ -294,7 +294,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { storagePwd, authNonce, timeMs, - deviceKeyFromDb, + clientKeyFromDb, signatureB64 ); } catch (UnsupportedOperationException ex) { @@ -302,9 +302,9 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { req, 422, "UNSUPPORTED_KEY_ALGORITHM", - "deviceKey algorithm is not supported" + "clientKey algorithm is not supported" ); - closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: unsupported device key algorithm"); + closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: unsupported client key algorithm"); return err; } catch (IllegalArgumentException ex) { Net_Response err = NetExceptionResponseFactory.error( @@ -440,11 +440,11 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { String storagePwd, String authNonce, long timeMs, - String deviceKey, + String clientKey, String signatureB64 ) throws IllegalArgumentException { - byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(deviceKey, "deviceKey"); + byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(clientKey, "clientKey"); byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); String preimageStr = "AUTH_CREATE_SESSION:" diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java index 6b76347..9ae3e6e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java @@ -48,9 +48,9 @@ public final class SolanaUserPdaImportService { boolean inserted = UserCreateDAO.getInstance().insertUserWithBlockchain( parsed.login, parsed.blockchainName, - parsed.deviceKeyB64, // в текущей модели solanaKey = deviceKey + parsed.clientKeyB64, // в текущей модели solanaKey = clientKey parsed.blockchainKeyB64, - parsed.deviceKeyB64, + parsed.clientKeyB64, sizeLimit, now ); @@ -158,7 +158,7 @@ public final class SolanaUserPdaImportService { int blocksCount = u8(raw, c++); String blockchainName = null; byte[] blockchainKey32 = null; - byte[] deviceKey32 = null; + byte[] clientKey32 = null; long paidLimitBytes = 0L; List sessions = new ArrayList<>(); @@ -170,7 +170,7 @@ public final class SolanaUserPdaImportService { if (blockType == 1) { c += 32; } else if (blockType == 2) { - deviceKey32 = slice(raw, c, 32); + clientKey32 = slice(raw, c, 32); c += 32; } else if (blockType == 3) { int count = u8(raw, c++); @@ -245,12 +245,12 @@ public final class SolanaUserPdaImportService { if (c > recordLen) return null; } - if (blockchainName == null || blockchainKey32 == null || deviceKey32 == null) return null; + if (blockchainName == null || blockchainKey32 == null || clientKey32 == null) return null; return new ParsedSolanaUser( login, blockchainName, Base64.getEncoder().encodeToString(blockchainKey32), - Base64.getEncoder().encodeToString(deviceKey32), + Base64.getEncoder().encodeToString(clientKey32), paidLimitBytes, sessions ); @@ -318,7 +318,7 @@ public final class SolanaUserPdaImportService { String login, String blockchainName, String blockchainKeyB64, - String deviceKeyB64, + String clientKeyB64, long paidLimitBytes, List sessions ) {} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/all_files.txt b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/all_files.txt deleted file mode 100644 index d89a693..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/all_files.txt +++ /dev/null @@ -1,1439 +0,0 @@ -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 1 авторизации: запрос выдачи одноразового nonce (authNonce). - * - * Клиент по логину просит сервер сгенерировать случайный authNonce, - * который будет использован на втором шаге при подписи. - * - * Формат входящего JSON: - * { - * "op": "AuthChallenge", - * "requestId": "...", - * "payload": { - * "login": "someLogin" - * } - * } - * - * Формат успешного ответа: - * { - * "op": "AuthChallenge", - * "requestId": "...", - * "status": 200, - * "payload": { - * "authNonce": "base64-строка-от-32-байт" - * } - * } - */ -public class Net_AuthChallenge_Request extends Net_Request { - - /** - * Логин пользователя, для которого запускается авторизация. - */ - private String login; - - public String getLogin() { - return login; - } - public void setLogin(String login) { - this.login = login; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на AuthChallenge. - * - * При успехе сервер возвращает одноразовый nonce для подписи (authNonce), - * который клиент обязан использовать на втором шаге при формировании строки - * для цифровой подписи. - * - * JSON: - * { - * "op": "AuthChallenge", - * "requestId": "...", - * "status": 200, - * "payload": { - * "authNonce": "base64-строка-от-32-байт" - * } - * } - */ -public class Net_AuthChallenge_Response extends Net_Response { - - /** - * Одноразовый nonce для авторификации. - * Строка — это base64-представление 32 случайных байт. - */ - private String authNonce; - - public String getAuthNonce() { - return authNonce; - } - - public void setAuthNonce(String authNonce) { - this.authNonce = authNonce; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос CloseActiveSession — закрытие активной сессии пользователя. - * - * Новая логика (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Никаких подписей и "AUTH_IN_PROGRESS" здесь больше нет. - * - * payload: - * { - * "sessionId": "..." // опционально; если пусто — закрываем текущую - * } - */ -public class Net_CloseActiveSession_Request extends Net_Request { - - /** Идентификатор сессии, которую нужно закрыть. Может быть пустым. */ - private String sessionId; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на CloseActiveSession. - * - * При успехе: - * - status = 200; - * - payload = {}. - * - * Закрытие WebSocket-соединения может быть выполнено сразу (для другой сессии) - * или чуть позже (для текущей сессии) после отправки ответа. - */ -public class Net_CloseActiveSession_Response extends Net_Response { - // Дополнительных полей пока не требуется. -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 2 (v2): создание новой сессии ТОЛЬКО через deviceKey. - * - * Шаги: - * 1) AuthChallenge(login) -> authNonce - * 2) CreateAuthSession(storagePwd, sessionPubKeyB64, timeMs, signatureB64, clientInfo) - * - * Подпись deviceKey делается над строкой (UTF-8): - * AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce}:{sessionPubKeyB64}:{storagePwd} - * - * Важно: - * - sessionKey генерируется на клиенте, на сервер отправляется ТОЛЬКО sessionPubKeyB64 (32 bytes base64). - * - В БД active_sessions.session_key хранится sessionPubKeyB64. - */ -public class Net_CreateAuthSession_Request extends Net_Request { - - /** Клиентский пароль для хранения данных (base64 от 32 байт). */ - private String storagePwd; - - /** Публичный ключ сессии (sessionPubKey), base64 от 32 байт. */ - private String sessionPubKeyB64; - - /** Время на стороне клиента (мс с 1970-01-01). */ - private long timeMs; - - /** Подпись Ed25519(deviceKey) над строкой AUTH_CREATE_SESSION:... (base64). */ - private String signatureB64; - - /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ - private String clientInfo; - - public String getStoragePwd() { - return storagePwd; - } - - public void setStoragePwd(String storagePwd) { - this.storagePwd = storagePwd; - } - - public String getSessionPubKeyB64() { - return sessionPubKeyB64; - } - - public void setSessionPubKeyB64(String sessionPubKeyB64) { - this.sessionPubKeyB64 = sessionPubKeyB64; - } - - public long getTimeMs() { - return timeMs; - } - - public void setTimeMs(long timeMs) { - this.timeMs = timeMs; - } - - public String getSignatureB64() { - return signatureB64; - } - - public void setSignatureB64(String signatureB64) { - this.signatureB64 = signatureB64; - } - - public String getClientInfo() { - return clientInfo; - } - - public void setClientInfo(String clientInfo) { - this.clientInfo = clientInfo; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на CreateAuthSession (v2). - * - * При успехе сервер создаёт запись в active_sessions - * и возвращает идентификатор сессии sessionId. - * - * JSON: - * { - * "op": "CreateAuthSession", - * "requestId": "...", - * "status": 200, - * "payload": { - * "sessionId": "base64(32)" - * } - * } - */ -public class Net_CreateAuthSession_Response extends Net_Response { - - /** Идентификатор сессии, base64 от 32 байт. */ - private String sessionId; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос ListSessions — список активных сессий пользователя. - * - * Новая логика (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Пустой payload. - */ -public class Net_ListSessions_Request extends Net_Request { - // пусто -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.List; - -/** - * Ответ на ListSessions. - * - * При успехе: - * - status = 200; - * - payload: - * { - * "sessions": [ - * { - * "sessionId": "...", - * "clientInfoFromClient": "...", - * "clientInfoFromRequest": "...", - * "geo": "Country, City" | "unknown", - * "lastAuthirificatedAtMs": 1733310000000 - * }, - * ... - * ] - * } - */ -public class Net_ListSessions_Response extends Net_Response { - - /** - * Список активных сессий для текущего пользователя. - */ - private List sessions; - - public List getSessions() { - return sessions; - } - - public void setSessions(List sessions) { - this.sessions = sessions; - } - - /** - * Описание одной активной сессии. - */ - public static class SessionInfo { - - /** Идентификатор сессии, base64 от 32 байт. */ - private String sessionId; - - /** Что прислал клиент в CreateAuthSession/RefreshSession (clientInfo). */ - private String clientInfoFromClient; - - /** Краткая строка, собранная сервером из HTTP-запроса (UA, платформа и т.п.). */ - private String clientInfoFromRequest; - - /** Строка геолокации вида "Country, City" или "unknown". */ - private String geo; - - /** Время последней успешной авторизации/refresh (мс с 1970-01-01). */ - private long lastAuthirificatedAtMs; - - // --- getters / setters --- - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } - - public String getClientInfoFromClient() { - return clientInfoFromClient; - } - - public void setClientInfoFromClient(String clientInfoFromClient) { - this.clientInfoFromClient = clientInfoFromClient; - } - - public String getClientInfoFromRequest() { - return clientInfoFromRequest; - } - - public void setClientInfoFromRequest(String clientInfoFromRequest) { - this.clientInfoFromRequest = clientInfoFromRequest; - } - - public String getGeo() { - return geo; - } - - public void setGeo(String geo) { - this.geo = geo; - } - - public long getLastAuthirificatedAtMs() { - return lastAuthirificatedAtMs; - } - - public void setLastAuthirificatedAtMs(long lastAuthirificatedAtMs) { - this.lastAuthirificatedAtMs = lastAuthirificatedAtMs; - } - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 1 входа в существующую сессию (v2): - * SessionChallenge(sessionId) -> nonce - */ -public class Net_SessionChallenge_Request extends Net_Request { - - private String sessionId; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на SessionChallenge (v2). - * payload: { "nonce": "base64(32)" } - */ -public class Net_SessionChallenge_Response extends Net_Response { - - private String nonce; - - public String getNonce() { - return nonce; - } - - public void setNonce(String nonce) { - this.nonce = nonce; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Шаг 2 входа в существующую сессию (v2): - * SessionLogin(sessionId, timeMs, signatureB64) -> storagePwd, AUTH_STATUS_USER - * - * Подпись делается sessionKey (приватный ключ на устройстве) над строкой (UTF-8): - * SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} - * - * nonce берётся из SessionChallenge и хранится в ctx (одноразовый, TTL). - */ -public class Net_SessionLogin_Request extends Net_Request { - - private String sessionId; - private long timeMs; - private String signatureB64; - - /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ - private String clientInfo; - - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } - - public long getTimeMs() { - return timeMs; - } - - public void setTimeMs(long timeMs) { - this.timeMs = timeMs; - } - - public String getSignatureB64() { - return signatureB64; - } - - public void setSignatureB64(String signatureB64) { - this.signatureB64 = signatureB64; - } - - public String getClientInfo() { - return clientInfo; - } - - public void setClientInfo(String clientInfo) { - this.clientInfo = clientInfo; - } -} -package server.logic.ws_protocol.JSON.handlers.auth.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на SessionLogin (v2). - * payload: { "storagePwd": "base64(32)" } - */ -public class Net_SessionLogin_Response extends Net_Response { - - private String storagePwd; - - public String getStoragePwd() { - return storagePwd; - } - - public void setStoragePwd(String storagePwd) { - this.storagePwd = storagePwd; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import server.logic.ws_protocol.Base64Ws; -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_AuthChallenge_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_AuthChallenge_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.SolanaUserEntry; - -import java.security.SecureRandom; - -/** - * AuthChallenge (v2) — шаг 1 создания новой сессии. - * - * Логика авторизации (v2): - * - Создание новой сессии возможно ТОЛЬКО через deviceKey пользователя. - * - Этот handler выдаёт одноразовый authNonce, который клиент использует во втором шаге: - * CreateAuthSession(..., signature(deviceKey, AUTH_CREATE_SESSION:...)) - * - * Что делает: - * 1) Проверяет login. - * 2) Находит пользователя (solana_users). - * 3) Пишет solanaUser в ctx, ставит AUTH_STATUS_AUTH_IN_PROGRESS. - * 4) Генерирует authNonce (base64url(32)) и сохраняет в ctx.authNonce. - */ -public class Net_AuthChallenge_Handler implements JsonMessageHandler { - - private static final SecureRandom RANDOM = new SecureRandom(); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - - Net_AuthChallenge_Request req = (Net_AuthChallenge_Request) baseReq; - - String login = req.getLogin(); - if (login == null || login.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_LOGIN", - "Пустой логин" - ); - } - - // Если по этому соединению уже есть залогиненный пользователь — не даём повторную авторификацию - if (ctx.getLogin() != null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "ALREADY_AUTHED", - "Попытка повторной авторификации для уже заданного login=" + ctx.getLogin() - ); - } - - SolanaUserEntry solanaUserEntry = SolanaUsersDAO.getInstance().getByLogin(login); - if (solanaUserEntry == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "UNKNOWN_USER", - "Пользователь с таким логином не найден" - ); - } - - ctx.setSolanaUser(solanaUserEntry); - ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS); - - byte[] buf = new byte[32]; - RANDOM.nextBytes(buf); - String authNonce = Base64Ws.encode(buf); - - ctx.setAuthNonce(authNonce); - - Net_AuthChallenge_Response resp = new Net_AuthChallenge_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setAuthNonce(authNonce); - - return resp; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -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_CloseActiveSession_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import server.ws.WsConnectionUtils; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; - -import java.sql.SQLException; - -/** - * CloseActiveSession (v2) — закрытие текущей или другой сессии. - * - * Логика авторизации (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Никаких подписей и AUTH_IN_PROGRESS здесь больше нет. - * - * Закрытие: - * - удаляем запись из БД - * - если по sessionId есть активный WS — закрываем его - */ -public class Net_CloseActiveSession_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_CloseActiveSession_Handler.class); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_CloseActiveSession_Request req = (Net_CloseActiveSession_Request) baseReq; - - if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "NOT_AUTHENTICATED", - "Операция доступна только для авторизованных пользователей" - ); - } - - SolanaUserEntry user = ctx.getSolanaUser(); - String currentLogin = user.getLogin(); - - String targetSessionId = req.getSessionId(); - if (targetSessionId == null || targetSessionId.isBlank()) { - if (ctx.getSessionId() != null && !ctx.getSessionId().isBlank()) { - targetSessionId = ctx.getSessionId(); - } else if (ctx.getActiveSession() != null && ctx.getActiveSession().getSessionId() != null) { - targetSessionId = ctx.getActiveSession().getSessionId(); - } else { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_SESSION_TO_CLOSE", - "Не удалось определить, какую сессию нужно закрыть" - ); - } - } - - ActiveSessionEntry targetSession; - try { - targetSession = ActiveSessionsDAO.getInstance().getBySessionId(targetSessionId); - } catch (SQLException e) { - log.error("Ошибка БД при поиске сессии для CloseActiveSession sessionId={}", targetSessionId, e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка доступа к базе данных при поиске сессии" - ); - } - - if (targetSession == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_NOT_FOUND", - "Сессия для закрытия не найдена" - ); - } - - if (currentLogin == null || !currentLogin.equals(targetSession.getLogin())) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_OF_ANOTHER_USER", - "Нельзя закрывать сессию другого пользователя" - ); - } - - boolean isCurrentSession = targetSessionId.equals(ctx.getSessionId()); - - closeActiveSession(targetSessionId, ctx, isCurrentSession); - - Net_CloseActiveSession_Response resp = new Net_CloseActiveSession_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - return resp; - } - - private void closeActiveSession(String targetSessionId, - ConnectionContext currentCtx, - boolean isCurrentSession) { - - try { - ActiveSessionsDAO.getInstance().deleteBySessionId(targetSessionId); - } catch (SQLException e) { - log.error("Ошибка БД при удалении сессии sessionId={}", targetSessionId, e); - } - - ConnectionContext ctxToClose = - ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId); - - if (ctxToClose == null) return; - - if (isCurrentSession && ctxToClose == currentCtx) { - new Thread(() -> { - try { Thread.sleep(50); } catch (InterruptedException ignored) {} - WsConnectionUtils.closeConnection( - ctxToClose, - 4000, - "Session closed by client via CloseActiveSession" - ); - }, "CloseSession-" + targetSessionId).start(); - } else { - WsConnectionUtils.closeConnection( - ctxToClose, - 4000, - "Session closed by client via CloseActiveSession" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.Base64Ws; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -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_CreateAuthSession_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CreateAuthSession_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import server.ws.WsConnectionUtils; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; -import shine.geo.ClientInfoService; -import shine.geo.GeoLookupService; -import utils.crypto.Ed25519Util; - -import org.eclipse.jetty.websocket.api.Session; - -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.sql.SQLException; - -/** - * CreateAuthSession (v2) — шаг 2 создания новой сессии (ТОЛЬКО deviceKey). - * - * Логика авторизации (v2): - * - Создание сессии: AuthChallenge(login) -> authNonce -> CreateAuthSession(...) - * - Клиент генерирует sessionKey (Ed25519), хранит приватный ключ у себя, - * отправляет на сервер ТОЛЬКО sessionPubKeyB64. - * - Сервер сохраняет sessionPubKeyB64 в active_sessions.session_key. - * - * Подпись deviceKey (Ed25519) проверяется над строкой (UTF-8): - * AUTH_CREATE_SESSION:{login}:{timeMs}:{authNonce} - * - * На выходе: - * - создаётся запись active_sessions - * - ctx становится AUTH_STATUS_USER (вход выполнен как "текущая сессия") - * - ответ: sessionId - */ -public class Net_CreateAuthSession__Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class); - private static final SecureRandom RANDOM = new SecureRandom(); - - public static final long ALLOWED_SKEW_MS = 30_000L; - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - - Net_CreateAuthSession_Request req = (Net_CreateAuthSession_Request) baseReq; - - if (ctx == null - || ctx.getSolanaUser() == null - || ctx.getAuthNonce() == null - || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_AUTH_IN_PROGRESS) { - - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_STEP1_CONTEXT", - "Шаг 1 авторизации не был корректно выполнен для данного соединения" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no step1 context or bad auth state"); - return err; - } - - SolanaUserEntry user = ctx.getSolanaUser(); - String login = user.getLogin(); - if (login == null || login.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "NO_LOGIN", - "Для пользователя не задан login в БД" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no login"); - return err; - } - - String storagePwd = req.getStoragePwd(); - if (storagePwd == null || storagePwd.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_STORAGE_PWD", - "Пустой storagePwd" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty storagePwd"); - return err; - } - - String sessionPubKeyB64 = req.getSessionPubKeyB64(); - if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SESSION_PUBKEY", - "Пустой sessionPubKeyB64" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty session pubkey"); - return err; - } - - // Проверим, что sessionPubKeyB64 декодируется в 32 байта - byte[] sessionPubKey32; - try { - sessionPubKey32 = Base64Ws.decode(sessionPubKeyB64); - } catch (IllegalArgumentException e) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "Некорректный base64 в sessionPubKeyB64" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session pubkey base64"); - return err; - } - if (sessionPubKey32.length != 32) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_SESSION_PUBKEY_LEN", - "sessionPubKey должен быть 32 байта" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad session pubkey length"); - return err; - } - - String signatureB64 = req.getSignatureB64(); - if (signatureB64 == null || signatureB64.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SIGNATURE", - "Пустая цифровая подпись" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: empty signature"); - return err; - } - - long timeMs = req.getTimeMs(); - long nowMs = System.currentTimeMillis(); - long diff = Math.abs(nowMs - timeMs); - if (diff > ALLOWED_SKEW_MS) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "TIME_SKEW", - "Время клиента отличается от сервера более чем на 30 секунд" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: time skew"); - return err; - } - - String clientInfoFromClient = req.getClientInfo(); - if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) { - clientInfoFromClient = clientInfoFromClient.substring(0, 50); - } - - String devicePubKeyB64 = user.getDeviceKey(); - if (devicePubKeyB64 == null || devicePubKeyB64.isBlank()) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_DEVICE_KEY", - "Отсутствует deviceKey у пользователя" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: no deviceKey"); - return err; - } - - String authNonce = ctx.getAuthNonce(); - - boolean sigOk; - try { - sigOk = verifyCreateSessionSignature( - user, - login, - authNonce, - timeMs, - signatureB64 - ); - } catch (IllegalArgumentException ex) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "Некорректный формат Base64 для ключа или подписи" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad base64"); - return err; - } - - if (!sigOk) { - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "BAD_SIGNATURE", - "Подпись не прошла проверку" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: bad signature"); - return err; - } - - // --- генерируем sessionId --- - String sessionId = generateRandom32B64Url(); - long now = System.currentTimeMillis(); - - // --- Сбор данных о клиенте (IP, UA, язык) --- - Session wsSession = ctx.getWsSession(); - String clientInfoFromRequest = ClientInfoService.buildClientInfoString(wsSession); - String userLanguage = ClientInfoService.extractPreferredLanguageTag(wsSession); - - String clientIp = ""; - if (wsSession != null) { - String ip = ClientInfoService.extractClientIp(wsSession); - if (ip != null) clientIp = ip; - - if (!clientIp.isBlank()) { - try { - GeoLookupService.resolveCountryCityOrIpWithCache(clientIp); - } catch (Exception e) { - log.debug("Geo lookup failed for ip={}", clientIp, e); - } - } - } - - // --- создаём запись ActiveSession и сохраняем в БД --- - ActiveSessionsDAO dao = ActiveSessionsDAO.getInstance(); - ActiveSessionEntry activeSessionEntry; - - try { - activeSessionEntry = new ActiveSessionEntry( - sessionId, - login, - sessionPubKeyB64, // session_key (pubkey) - storagePwd, - now, - now, - null, // pushEndpoint - null, // pushP256dhKey - null, // pushAuthKey - clientIp, - clientInfoFromClient, - clientInfoFromRequest, - userLanguage - ); - - dao.insert(activeSessionEntry); - } catch (SQLException e) { - log.error("Ошибка БД при создании новой сессии для login={}", login, e); - Net_Response err = NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR_SESSION_CREATE", - "Ошибка БД при создании сессии" - ); - WsConnectionUtils.closeConnection(ctx, 4001, "Auth failed: db error"); - return err; - } - - // --- обновляем контекст --- - ctx.setActiveSession(activeSessionEntry); - ctx.setSessionId(sessionId); - ctx.setAuthNonce(null); - ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); - - ActiveConnectionsRegistry.getInstance().register(ctx); - - // --- формируем ответ --- - Net_CreateAuthSession_Response resp = new Net_CreateAuthSession_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setSessionId(sessionId); - return resp; - } - - private static boolean verifyCreateSessionSignature( - SolanaUserEntry user, - String login, - String authNonce, - long timeMs, - String signatureB64 - ) throws IllegalArgumentException { - - // deviceKey (pub, 32) - byte[] publicKey32 = Ed25519Util.keyFromBase64(user.getDeviceKey()); - byte[] signature64 = Base64Ws.decode(signatureB64); - - String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce; - byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); - - return Ed25519Util.verify(preimage, signature64, publicKey32); - } - - private static String generateRandom32B64Url() { - byte[] buf = new byte[32]; - RANDOM.nextBytes(buf); - return Base64Ws.encode(buf); - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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_ListSessions_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Response; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListSessions_Response.SessionInfo; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; -import shine.geo.GeoLookupService; - -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.List; - -/** - * ListSessions (v2) — список активных сессий. - * - * Логика авторизации (v2): - * - Доступно ТОЛЬКО после успешного входа в сессию (AUTH_STATUS_USER). - * - Никаких подписей здесь больше нет. - */ -public class Net_ListSessions_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_ListSessions_Handler.class); - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_ListSessions_Request req = (Net_ListSessions_Request) baseReq; - - if (ctx == null || ctx.getSolanaUser() == null || ctx.getAuthenticationStatus() != ConnectionContext.AUTH_STATUS_USER) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "NOT_AUTHENTICATED", - "Операция доступна только для авторизованных пользователей" - ); - } - - SolanaUserEntry user = ctx.getSolanaUser(); - String currentLogin = user.getLogin(); - - List sessions; - try { - sessions = ActiveSessionsDAO.getInstance().getByLogin(currentLogin); - } catch (SQLException e) { - log.error("Ошибка БД при получении списка сессий для login={}", currentLogin, e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR_LIST_SESSIONS", - "Ошибка доступа к базе данных при получении списка сессий" - ); - } - - List resultList = new ArrayList<>(); - for (ActiveSessionEntry s : sessions) { - SessionInfo info = new SessionInfo(); - info.setSessionId(s.getSessionId()); - info.setClientInfoFromClient(s.getClientInfoFromClient()); - info.setClientInfoFromRequest(s.getClientInfoFromRequest()); - info.setLastAuthirificatedAtMs(s.getLastAuthirificatedAtMs()); - - String ip = s.getClientIp(); - String geo = GeoLookupService.resolveCountryCityOrIpWithCache(ip); - info.setGeo(geo); - - resultList.add(info); - } - - Net_ListSessions_Response resp = new Net_ListSessions_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setSessions(resultList); - - return resp; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import server.logic.ws_protocol.Base64Ws; -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_SessionChallenge_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.entities.ActiveSessionEntry; - -import java.security.SecureRandom; -import java.sql.SQLException; - -/** - * SessionChallenge (v2) — шаг 1 входа в существующую сессию. - * - * Логика авторизации (v2): - * - Вход в существующую сессию ВСЕГДА в 2 шага: - * 1) SessionChallenge(sessionId) -> nonce - * 2) SessionLogin(sessionId, timeMs, signature(sessionKey, SESSION_LOGIN:...)) - * - * Что делает: - * - Проверяет, что sessionId существует в БД. - * - Генерирует одноразовый nonce (base64url(32)), сохраняет его в ctx: - * ctx.sessionLoginNonce, ctx.sessionLoginSessionId, ctx.sessionLoginNonceExpiresAtMs. - */ -public class Net_SessionChallenge_Handler implements JsonMessageHandler { - - private static final SecureRandom RANDOM = new SecureRandom(); - private static final long NONCE_TTL_MS = 60_000L; - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_SessionChallenge_Request req = (Net_SessionChallenge_Request) baseReq; - - String sessionId = req.getSessionId(); - if (sessionId == null || sessionId.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SESSION_ID", - "Пустой sessionId" - ); - } - - ActiveSessionEntry session; - try { - session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId); - } catch (SQLException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка доступа к базе данных" - ); - } - - if (session == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_NOT_FOUND", - "Сессия не найдена" - ); - } - - byte[] buf = new byte[32]; - RANDOM.nextBytes(buf); - String nonce = Base64Ws.encode(buf); - - long now = System.currentTimeMillis(); - ctx.setSessionLoginNonce(nonce); - ctx.setSessionLoginSessionId(sessionId); - ctx.setSessionLoginNonceExpiresAtMs(now + NONCE_TTL_MS); - - Net_SessionChallenge_Response resp = new Net_SessionChallenge_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setNonce(nonce); - return resp; - } -} -package server.logic.ws_protocol.JSON.handlers.auth; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.Base64Ws; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -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_SessionLogin_Request; -import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.dao.ActiveSessionsDAO; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.ActiveSessionEntry; -import shine.db.entities.SolanaUserEntry; -import shine.geo.ClientInfoService; -import shine.geo.GeoLookupService; -import utils.crypto.Ed25519Util; - -import java.nio.charset.StandardCharsets; -import java.sql.SQLException; - -/** - * SessionLogin (v2) — шаг 2 входа в существующую сессию (по sessionKey). - * - * Логика авторизации (v2): - * - SessionChallenge(sessionId) выдаёт nonce (одноразовый, TTL). - * - SessionLogin проверяет подпись sessionKey над строкой: - * SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} - * - sessionPubKey берём из БД: active_sessions.session_key (base64 32 bytes). - * - * При успехе: - * - ctx становится AUTH_STATUS_USER - * - обновляем метаданные сессии (lastAuth + clientIp + clientInfo + lang) - * - возвращаем storagePwd - */ -public class Net_SessionLogin_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_SessionLogin_Handler.class); - - private static final long ALLOWED_SKEW_MS = 30_000L; - - @Override - public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { - Net_SessionLogin_Request req = (Net_SessionLogin_Request) baseReq; - - String sessionId = req.getSessionId(); - if (sessionId == null || sessionId.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SESSION_ID", - "Пустой sessionId" - ); - } - - // проверка челленджа - if (ctx.getSessionLoginNonce() == null - || ctx.getSessionLoginSessionId() == null - || System.currentTimeMillis() > ctx.getSessionLoginNonceExpiresAtMs()) { - - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "NO_CHALLENGE", - "Нет активного SessionChallenge или nonce истёк" - ); - } - - if (!sessionId.equals(ctx.getSessionLoginSessionId())) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "SESSION_ID_MISMATCH", - "nonce был выдан для другого sessionId" - ); - } - - long timeMs = req.getTimeMs(); - long nowMs = System.currentTimeMillis(); - if (Math.abs(nowMs - timeMs) > ALLOWED_SKEW_MS) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "TIME_SKEW", - "Время клиента отличается от сервера более чем на 30 секунд" - ); - } - - String signatureB64 = req.getSignatureB64(); - if (signatureB64 == null || signatureB64.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "EMPTY_SIGNATURE", - "Пустая подпись" - ); - } - - ActiveSessionEntry session; - try { - session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId); - } catch (SQLException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка доступа к базе данных" - ); - } - - if (session == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "SESSION_NOT_FOUND", - "Сессия не найдена" - ); - } - - String sessionPubKeyB64 = session.getSessionKey(); // это pubKey (Base64(32)) - if (sessionPubKeyB64 == null || sessionPubKeyB64.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "NO_SESSION_KEY", - "В сессии не задан session_key" - ); - } - - String nonce = ctx.getSessionLoginNonce(); - - boolean sigOk; - try { - sigOk = verifySessionLoginSignature(sessionPubKeyB64, sessionId, timeMs, nonce, signatureB64); - } catch (IllegalArgumentException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "Некорректный Base64 для ключа/подписи" - ); - } - - if (!sigOk) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "BAD_SIGNATURE", - "Подпись не прошла проверку" - ); - } - - // сжигаем nonce - ctx.setSessionLoginNonce(null); - ctx.setSessionLoginSessionId(null); - ctx.setSessionLoginNonceExpiresAtMs(0); - - // подтягиваем пользователя - SolanaUserEntry user; - try { - user = SolanaUsersDAO.getInstance().getByLogin(session.getLogin()); - } catch (SQLException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR_USER_LOOKUP", - "Ошибка доступа к базе данных при получении пользователя" - ); - } - - if (user == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "USER_NOT_FOUND_FOR_SESSION", - "Пользователь для данной сессии не найден" - ); - } - - // обновление метаданных - String clientInfoFromClient = req.getClientInfo(); - if (clientInfoFromClient != null && clientInfoFromClient.length() > 50) { - clientInfoFromClient = clientInfoFromClient.substring(0, 50); - } - - String clientIp = null; - String clientInfoFromRequest = null; - String userLanguage = null; - - if (ctx.getWsSession() != null) { - clientIp = ClientInfoService.extractClientIp(ctx.getWsSession()); - clientInfoFromRequest = ClientInfoService.buildClientInfoString(ctx.getWsSession()); - userLanguage = ClientInfoService.extractPreferredLanguageTag(ctx.getWsSession()); - - if (clientIp != null && !clientIp.isBlank()) { - try { - GeoLookupService.resolveCountryCityOrIpWithCache(clientIp); - } catch (Exception e) { - log.debug("Geo lookup failed for ip={}", clientIp, e); - } - } - } - - long now = System.currentTimeMillis(); - try { - ActiveSessionsDAO.getInstance().updateOnRefresh( - sessionId, - now, - clientIp, - clientInfoFromClient, - clientInfoFromRequest, - userLanguage - ); - } catch (SQLException e) { - log.error("Ошибка БД при updateOnRefresh sessionId={}", sessionId, e); - } - - session.setLastAuthirificatedAtMs(now); - session.setClientIp(clientIp); - session.setClientInfoFromClient(clientInfoFromClient); - session.setClientInfoFromRequest(clientInfoFromRequest); - session.setUserLanguage(userLanguage); - - // ctx - ctx.setActiveSession(session); - ctx.setSolanaUser(user); - ctx.setSessionId(sessionId); - ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); - - ActiveConnectionsRegistry.getInstance().register(ctx); - - // ответ - Net_SessionLogin_Response resp = new Net_SessionLogin_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - resp.setStoragePwd(session.getStoragePwd()); - return resp; - } - - private static boolean verifySessionLoginSignature( - String sessionPubKeyB64, - String sessionId, - long timeMs, - String nonce, - String signatureB64 - ) throws IllegalArgumentException { - - // pubKey: Base64(32). (Ed25519Util.keyFromBase64 должен использовать стандартный Base64) - byte[] publicKey32 = Ed25519Util.keyFromBase64(sessionPubKeyB64); - - // signature: Base64(64) через единую утилиту WS-протокола - byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); - - String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce; - byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); - - return Ed25519Util.verify(preimage, signature64, publicKey32); - } -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/concat_to_file.sh b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/concat_to_file.sh deleted file mode 100755 index f6db1f1..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/concat_to_file.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# собрать только *.java файлы и вывести их содержимое в файл -find . -type f -name "*.java" | sort | while read -r f; do - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Все .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/AUTH_SESSION_NEW.md b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/AUTH_SESSION_NEW.md index a15fab3..9e6ab85 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/AUTH_SESSION_NEW.md +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/AUTH_SESSION_NEW.md @@ -35,7 +35,7 @@ 1. Добавление пользователя (AddUser) -Назначение: создать локальную запись пользователя с двумя ключами — solanaKey и deviceKey. +Назначение: создать локальную запись пользователя с двумя ключами — solanaKey и clientKey. 📤 Запрос клиента { @@ -46,7 +46,7 @@ "loginId": 100212, "bchId": 4222, "solanaKey": "BASE64_LOGIN_KEY", -"deviceKey": "BASE64_DEVICE_KEY", +"clientKey": "BASE64_DEVICE_KEY", "bchLimit": 1000000 } } @@ -62,7 +62,7 @@ login TEXT NOT NULL, loginId INTEGER PRIMARY KEY, bchId INTEGER NOT NULL, solanaKey TEXT, -deviceKey TEXT, +clientKey TEXT, bchLimit INTEGER ); @@ -118,7 +118,7 @@ timeMs — timestamp клиента (UTC). sessionPwd — строка с шага 1. -signatureB64 — Ed25519‐подпись preimage приватным ключом deviceKey. +signatureB64 — Ed25519‐подпись preimage приватным ключом clientKey. 📤 Запрос клиента { @@ -141,7 +141,7 @@ signatureB64 — Ed25519‐подпись preimage приватным ключо Восстанавливает preimage. -Находит deviceKey пользователя. +Находит clientKey пользователя. Проверяет Ed25519-подпись. diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java index 91fa95b..cd4f99b 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CreateAuthSession_Request.java @@ -3,13 +3,13 @@ package server.logic.ws_protocol.JSON.handlers.auth.entyties; import server.logic.ws_protocol.JSON.entyties.Net_Request; /** - * Шаг 2 (v2): создание новой сессии ТОЛЬКО через deviceKey. + * Шаг 2 (v2): создание новой сессии ТОЛЬКО через clientKey. * * Шаги: * 1) AuthChallenge(login) -> authNonce - * 2) CreateAuthSession(login, sessionKey, storagePwd, timeMs, authNonce, deviceKey, signatureB64, clientInfo) + * 2) CreateAuthSession(login, sessionKey, storagePwd, timeMs, authNonce, clientKey, signatureB64, clientInfo) * - * Подпись deviceKey делается над строкой (UTF-8): + * Подпись clientKey делается над строкой (UTF-8): * AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} * * Важно: @@ -33,9 +33,9 @@ public class Net_CreateAuthSession_Request extends Net_Request { private String authNonce; /** Публичный ключ устройства пользователя. */ - private String deviceKey; + private String clientKey; - /** Подпись Ed25519(deviceKey) над строкой AUTH_CREATE_SESSION:... (base64). */ + /** Подпись Ed25519(clientKey) над строкой AUTH_CREATE_SESSION:... (base64). */ private String signatureB64; /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ @@ -87,12 +87,12 @@ public class Net_CreateAuthSession_Request extends Net_Request { this.authNonce = authNonce; } - public String getDeviceKey() { - return deviceKey; + public String getClientKey() { + return clientKey; } - public void setDeviceKey(String deviceKey) { - this.deviceKey = deviceKey; + public void setClientKey(String clientKey) { + this.clientKey = clientKey; } public String getSignatureB64() { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/concat_to_file.sh b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/concat_to_file.sh deleted file mode 100755 index f6db1f1..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/concat_to_file.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# собрать только *.java файлы и вывести их содержимое в файл -find . -type f -name "*.java" | sort | while read -r f; do - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Все .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/concat_to_file.sh b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/concat_to_file.sh deleted file mode 100755 index f6db1f1..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/concat_to_file.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# собрать только *.java файлы и вывести их содержимое в файл -find . -type f -name "*.java" | sort | while read -r f; do - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Все .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/all_files.txt b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/all_files.txt deleted file mode 100644 index 430f54f..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/all_files.txt +++ /dev/null @@ -1,180 +0,0 @@ -package server.logic.ws_protocol.JSON.handlers.connections.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос GetFriendsLists — получить два списка "друзей" по connections_state. - * - * { - * "op": "GetFriendsLists", - * "requestId": "req-100", - * "payload": { - * "login": "anya" - * } - * } - * - * Возвращает: - * - out_friends: кому login поставил FRIEND - * - in_friends: кто поставил FRIEND этому login - * - * ПРО ДОСТУП (на будущее): - * Сейчас (MVP) без ограничений. Позже можно ограничить видимость связей. - */ -public class Net_GetFriendsLists_Request extends Net_Request { - - private String login; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } -} -package server.logic.ws_protocol.JSON.handlers.connections.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.ArrayList; -import java.util.List; - -/** - * Ответ GetFriendsLists. - * - * { - * "op": "GetFriendsLists", - * "requestId": "req-100", - * "status": 200, - * "payload": { - * "login": "Anya", // канонический регистр из БД - * "out_friends": ["Bob", "Kate"], // кому login поставил FRIEND - * "in_friends": ["Alex", "Kate"] // кто поставил FRIEND login - * } - * } - */ -public class Net_GetFriendsLists_Response extends Net_Response { - - private String login; - - private List out_friends = new ArrayList<>(); - private List in_friends = new ArrayList<>(); - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public List getOut_friends() { return out_friends; } - public void setOut_friends(List out_friends) { this.out_friends = out_friends; } - - public List getIn_friends() { return in_friends; } - public void setIn_friends(List in_friends) { this.in_friends = in_friends; } -} -package server.logic.ws_protocol.JSON.handlers.connections; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.connections.entyties.Net_GetFriendsLists_Request; -import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.MsgSubType; -import shine.db.SqliteDbController; -import shine.db.dao.ConnectionsStateDAO; - -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.util.List; - -/** - * GetFriendsLists — получить 2 списка: - * - out_friends: кому login поставил FRIEND - * - in_friends: кто поставил FRIEND этому login - * - * ВАЖНО: - * - login в запросе может быть любым регистром - * - в ответе возвращаем канонический регистр (как в solana_users.login) - * - * ПРИМЕЧАНИЕ: - * Таблица пользователей тут названа "solana_users". Если у тебя иначе — поменяй SQL. - */ -public class Net_GetFriendsLists_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_GetFriendsLists_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_GetFriendsLists_Request req = (Net_GetFriendsLists_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login" - ); - } - - final String loginAnyCase = req.getLogin().trim(); - - try { - SqliteDbController db = SqliteDbController.getInstance(); - ConnectionsStateDAO dao = ConnectionsStateDAO.getInstance(); - - try (Connection c = db.getConnection()) { - - // 1) Канонизируем login через solana_users (NOCASE) - String canonicalLogin = findCanonicalLogin(c, loginAnyCase); - if (canonicalLogin == null) { - return NetExceptionResponseFactory.error( - req, - 404, - "USER_NOT_FOUND", - "Пользователь не найден" - ); - } - - int relType = (int) MsgSubType.CONNECTION_FRIEND; - - // 2) Два списка (логины канонические) - List outFriends = dao.listOutgoingByRelTypeCanonical(c, canonicalLogin, relType); - List inFriends = dao.listIncomingByRelTypeCanonical(c, canonicalLogin, relType); - - Net_GetFriendsLists_Response resp = new Net_GetFriendsLists_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - resp.setLogin(canonicalLogin); - resp.setOut_friends(outFriends); - resp.setIn_friends(inFriends); - - return resp; - } - - } catch (Exception e) { - log.error("❌ Internal error GetFriendsLists", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } - - private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception { - String sql = """ - SELECT login - FROM solana_users - WHERE login = ? COLLATE NOCASE - LIMIT 1 - """; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, loginAnyCase); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return rs.getString("login"); - } - } - } -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/concat_to_file.sh b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/concat_to_file.sh deleted file mode 100755 index f6db1f1..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/concat_to_file.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# собрать только *.java файлы и вывести их содержимое в файл -find . -type f -name "*.java" | sort | while read -r f; do - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Все .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java index 99b0405..9e28295 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java @@ -62,7 +62,7 @@ public class Net_GetUser_Handler implements JsonMessageHandler { resp.setBlockchainName(u.getBlockchainName()); resp.setSolanaKey(u.getSolanaKey()); resp.setBlockchainKey(u.getBlockchainKey()); - resp.setDeviceKey(u.getDeviceKey()); + resp.setClientKey(u.getClientKey()); // Возвращаем актуальный курсор блокчейна и, если запись состояния потеряна, // автоматически восстанавливаем её для существующего пользователя. diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/all_files.txt b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/all_files.txt deleted file mode 100644 index f226a58..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/all_files.txt +++ /dev/null @@ -1,240 +0,0 @@ -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос AddUser — временная/тестовая регистрация локального пользователя. - * - * Клиент отправляет: - * - * { - * "op": "AddUser", - * "requestId": "test-add-1", - * "payload": { - * "login": "anya", - * "blockchainName": "anya-001", - * "solanaKey": "base64-ed25519-public-key-login", - * "blockchainKey": "base64-ed25519-public-key-blockchain", - * "deviceKey": "base64-ed25519-public-key-device", - * "bchLimit": 1000000 - * } - * } - * - * Все поля лежат внутри payload. - */ -public class Net_AddUser_Request extends Net_Request { - - private String login; - private String blockchainName; - - /** Ключ пользователя Solana (публичный ключ логина) */ - private String solanaKey; - - /** Ключ блокчейна (публичный ключ блокчейна) */ - private String blockchainKey; - - /** Ключ устройства (публичный ключ устройства) */ - private String deviceKey; - - private Integer bchLimit; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getBlockchainName() { return blockchainName; } - public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } - - public String getSolanaKey() { return solanaKey; } - public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; } - - public String getBlockchainKey() { return blockchainKey; } - public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } - - public String getDeviceKey() { return deviceKey; } - public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } - - public Integer getBchLimit() { return bchLimit; } - public void setBchLimit(Integer bchLimit) { this.bchLimit = bchLimit; } -} -package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Успешный ответ на AddUser. - * - * Сейчас дополнительных полей нет — достаточно status=200. - * - * Пример: - * { - * "op": "AddUser", - * "requestId": "test-add-1", - * "status": 200, - * "payload": { } - * } - */ -public class Net_AddUser_Response extends Net_Response { - // При необходимости сюда можно добавить, например, флаг created/updated и т.п. -} -package server.logic.ws_protocol.JSON.handlers.tempToTest; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.tempToTest.entyties.Net_AddUser_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.BlockchainStateDAO; -import shine.db.dao.SolanaUsersDAO; -import shine.db.entities.BlockchainStateEntry; -import shine.db.entities.SolanaUserEntry; -import utils.blockchain.BlockchainNameUtil; - -import java.sql.Connection; -import java.sql.SQLException; -import java.util.Base64; - -public class Net_AddUser_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_AddUser_Handler.class); - - /** TEST ONLY */ - private static final int TEST_BCH_LIMIT = 1_000_000; - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_AddUser_Request req = (Net_AddUser_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank() - || req.getBlockchainName() == null || req.getBlockchainName().isBlank() - || req.getSolanaKey() == null || req.getSolanaKey().isBlank() - || req.getBlockchainKey() == null || req.getBlockchainKey().isBlank() - || req.getDeviceKey() == null || req.getDeviceKey().isBlank()) { - - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login/blockchainName/solanaKey/blockchainKey/deviceKey" - ); - } - - // blockchainName должен быть вида: -NNN - if (!BlockchainNameUtil.isBlockchainNameMatchesLogin(req.getBlockchainName(), req.getLogin())) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BLOCKCHAIN_NAME", - "blockchainName должен быть вида -NNN (пример: anya-001)" - ); - } - - int limit = (req.getBchLimit() == null || req.getBchLimit() <= 0) - ? TEST_BCH_LIMIT - : req.getBchLimit(); - - try { - byte[] blockchainKey32 = Base64.getDecoder().decode(req.getBlockchainKey()); - if (blockchainKey32.length != 32) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BLOCKCHAIN_KEY", - "blockchainKey должен быть Base64(32 bytes)" - ); - } - - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); - BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); - - SqliteDbController db = SqliteDbController.getInstance(); - - try (Connection c = db.getConnection()) { - c.setAutoCommit(false); - - // 1. Проверяем, что пользователя нет - if (usersDAO.getByLogin(req.getLogin()) != null) { - return NetExceptionResponseFactory.error( - req, - 409, - "USER_ALREADY_EXISTS", - "Пользователь с таким login уже существует" - ); - } - - // 2. Проверяем, что blockchain_state ещё нет - if (stateDAO.getByBlockchainName(req.getBlockchainName()) != null) { - return NetExceptionResponseFactory.error( - req, - 409, - "BLOCKCHAIN_ALREADY_EXISTS", - "blockchain_state уже существует" - ); - } - - // 3. Создаём пользователя (solanaKey + deviceKey) - SolanaUserEntry user = new SolanaUserEntry( - req.getLogin(), - req.getSolanaKey(), - req.getDeviceKey() - ); - - usersDAO.insert(c, user); - - // 4. Создаём INITIAL blockchain_state (blockchainKey) - BlockchainStateEntry st = new BlockchainStateEntry(); - st.setBlockchainName(req.getBlockchainName()); - st.setLogin(req.getLogin()); - st.setBlockchainKey(req.getBlockchainKey()); // Base64(32) - st.setLastBlockNumber(-1); - st.setLastBlockHash(new byte[32]); - st.setFileSizeBytes(0); - st.setSizeLimit(limit); - st.setUpdatedAtMs(System.currentTimeMillis()); - - stateDAO.upsert(c, st); - - c.commit(); - } - - Net_AddUser_Response resp = new Net_AddUser_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - log.info("✅ AddUser ok: login={}, blockchainName={}, limit={}", - req.getLogin(), req.getBlockchainName(), limit); - - return resp; - - } catch (IllegalArgumentException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_KEY_FORMAT", - e.getMessage() - ); - } catch (SQLException e) { - log.error("❌ DB error AddUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error AddUser", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/concat_to_file.sh b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/concat_to_file.sh deleted file mode 100755 index f6db1f1..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/concat_to_file.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# собрать только *.java файлы и вывести их содержимое в файл -find . -type f -name "*.java" | sort | while read -r f; do - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Все .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Request.java index 0a7ea45..28b6a6b 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_AddUser_Request.java @@ -15,7 +15,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request; * "blockchainName": "anya-001", * "solanaKey": "base64-ed25519-public-key-login", * "blockchainKey": "base64-ed25519-public-key-blockchain", - * "deviceKey": "base64-ed25519-public-key-device", + * "clientKey": "base64-ed25519-public-key-device", * "bchLimit": 1000000 * } * } @@ -34,7 +34,7 @@ public class Net_AddUser_Request extends Net_Request { private String blockchainKey; /** Ключ устройства (публичный ключ устройства) */ - private String deviceKey; + private String clientKey; private Integer bchLimit; @@ -50,8 +50,8 @@ public class Net_AddUser_Request extends Net_Request { public String getBlockchainKey() { return blockchainKey; } public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } - public String getDeviceKey() { return deviceKey; } - public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } + public String getClientKey() { return clientKey; } + public void setClientKey(String clientKey) { this.clientKey = clientKey; } public Integer getBchLimit() { return bchLimit; } public void setBchLimit(Integer bchLimit) { this.bchLimit = bchLimit; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_GetUser_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_GetUser_Response.java index f5ce1e6..0037ed2 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_GetUser_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/entyties/Net_GetUser_Response.java @@ -26,7 +26,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response; * "blockchainName": "anya-001", * "solanaKey": "...", * "blockchainKey": "...", - * "deviceKey": "..." + * "clientKey": "..." * } * } */ @@ -38,7 +38,7 @@ public class Net_GetUser_Response extends Net_Response { private String blockchainName; private String solanaKey; private String blockchainKey; - private String deviceKey; + private String clientKey; private Integer serverLastGlobalNumber; private String serverLastGlobalHash; private Long serverBlockchainSizeBytes; @@ -59,8 +59,8 @@ public class Net_GetUser_Response extends Net_Response { public String getBlockchainKey() { return blockchainKey; } public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } - public String getDeviceKey() { return deviceKey; } - public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } + public String getClientKey() { return clientKey; } + public void setClientKey(String clientKey) { this.clientKey = clientKey; } public Integer getServerLastGlobalNumber() { return serverLastGlobalNumber; } public void setServerLastGlobalNumber(Integer serverLastGlobalNumber) { this.serverLastGlobalNumber = serverLastGlobalNumber; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_GetUserParam_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_GetUserParam_Handler.java index 6179402..93c1298 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_GetUserParam_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_GetUserParam_Handler.java @@ -71,7 +71,7 @@ public class Net_GetUserParam_Handler implements JsonMessageHandler { resp.setParam(e.getParam()); resp.setTime_ms(e.getTimeMs()); resp.setValue(e.getValue()); - resp.setDevice_key(e.getDeviceKey()); + resp.setClient_key(e.getClientKey()); resp.setSignature(e.getSignature()); return resp; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_ListUserParams_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_ListUserParams_Handler.java index c70c79f..866a9c5 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_ListUserParams_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_ListUserParams_Handler.java @@ -73,7 +73,7 @@ public class Net_ListUserParams_Handler implements JsonMessageHandler { it.setParam(e.getParam()); it.setTime_ms(e.getTimeMs()); it.setValue(e.getValue()); - it.setDevice_key(e.getDeviceKey()); + it.setClient_key(e.getClientKey()); it.setSignature(e.getSignature()); items.add(it); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java index 1b4b019..1d1f369 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/Net_UpsertUserParam_Handler.java @@ -28,8 +28,8 @@ import java.sql.SQLException; * * Делает (MVP, без "сессий"): * 1) Проверка входных полей. - * 2) Проверка подписи Ed25519 по device_key. - * 3) Проверка, что пользователь существует и что device_key принадлежит этому login. + * 2) Проверка подписи Ed25519 по client_key. + * 3) Проверка, что пользователь существует и что client_key принадлежит этому login. * 4) Атомарная запись в БД "только если time_ms новее" (UPSERT + WHERE). * * ВАЖНО: @@ -50,14 +50,14 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler { || req.getParam() == null || req.getParam().isBlank() || req.getTime_ms() == null || req.getTime_ms() <= 0 || req.getValue() == null - || req.getDevice_key() == null || req.getDevice_key().isBlank() + || req.getClient_key() == null || req.getClient_key().isBlank() || req.getSignature() == null || req.getSignature().isBlank()) { return NetExceptionResponseFactory.error( req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", - "Некорректные поля: login/param/time_ms/value/device_key/signature" + "Некорректные поля: login/param/time_ms/value/client_key/signature" ); } @@ -65,7 +65,7 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler { final String param = req.getParam().trim(); final long timeMs = req.getTime_ms(); final String value = req.getValue(); - final String deviceKeyB64 = req.getDevice_key().trim(); + final String clientKeyB64 = req.getClient_key().trim(); final String signatureB64 = req.getSignature().trim(); try { @@ -73,14 +73,14 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler { byte[] pubKey32; byte[] sig64; try { - pubKey32 = Base64Ws.decodeLen(deviceKeyB64, 32, "device_key"); + pubKey32 = Base64Ws.decodeLen(clientKeyB64, 32, "client_key"); sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature"); } catch (IllegalArgumentException e) { return NetExceptionResponseFactory.error( req, WireCodes.Status.BAD_REQUEST, "BAD_BASE64", - "device_key/signature должны быть Base64" + "client_key/signature должны быть Base64" ); } @@ -120,23 +120,23 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler { ); } - // 2) device key must match the user's stored deviceKey - String userDeviceKey = user.getDeviceKey(); - if (userDeviceKey == null || userDeviceKey.isBlank()) { + // 2) client key must match the user's stored clientKey + String userClientKey = user.getClientKey(); + if (userClientKey == null || userClientKey.isBlank()) { return NetExceptionResponseFactory.error( req, WireCodes.Status.SERVER_DATA_ERROR, "USER_DEVICE_KEY_EMPTY", - "У пользователя не задан deviceKey в БД" + "У пользователя не задан clientKey в БД" ); } - if (!userDeviceKey.trim().equals(deviceKeyB64)) { + if (!userClientKey.trim().equals(clientKeyB64)) { return NetExceptionResponseFactory.error( req, 403, "DEVICE_KEY_MISMATCH", - "device_key не соответствует пользователю" + "client_key не соответствует пользователю" ); } @@ -146,7 +146,7 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler { param, timeMs, value, - deviceKeyB64, + clientKeyB64, signatureB64 ); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/all_files.txt b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/all_files.txt deleted file mode 100644 index 4db8a35..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/all_files.txt +++ /dev/null @@ -1,640 +0,0 @@ -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос GetUserParam — получить один параметр пользователя. - * - * { - * "op": "GetUserParam", - * "requestId": "req-1", - * "payload": { - * "login": "anya", - * "param": "feed:lastSeenGlobal" - * } - * } - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) этот запрос не ограничивает просмотр параметров, т.к. проект в тестовом режиме. - * Позже, вероятно, потребуется ограничить: кто и какие параметры может читать (сессия/права). - * Но для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_GetUserParam_Request extends Net_Request { - - private String login; - private String param; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ GetUserParam. - * - * Если найден: - * { - * "op": "GetUserParam", - * "requestId": "req-1", - * "status": 200, - * "payload": { - * "login": "anya", - * "param": "feed:lastSeenGlobal", - * "time_ms": 1736000000123, - * "value": "105", - * "device_key": "base64-32", - * "signature": "base64-64" - * } - * } - * - * Если не найден: - * status=404, payload пустой. - */ -public class Net_GetUserParam_Response extends Net_Response { - - private String login; - private String param; - private Long time_ms; - private String value; - private String device_key; - private String signature; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } - - public Long getTime_ms() { return time_ms; } - public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } - - public String getValue() { return value; } - public void setValue(String value) { this.value = value; } - - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос ListUserParams — получить все сохранённые параметры пользователя. - * - * { - * "op": "ListUserParams", - * "requestId": "req-2", - * "payload": { - * "login": "anya" - * } - * } - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) запрос не ограничивает просмотр параметров. - * В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры. - * Для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_ListUserParams_Request extends Net_Request { - - private String login; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -import java.util.ArrayList; -import java.util.List; - -/** - * Ответ ListUserParams — список всех параметров пользователя. - * - * { - * "op": "ListUserParams", - * "requestId": "req-2", - * "status": 200, - * "payload": { - * "login": "anya", - * "params": [ - * { - * "login": "anya", - * "param": "feed:lastSeenGlobal", - * "time_ms": 1736000000123, - * "value": "105", - * "device_key": "base64-32", - * "signature": "base64-64" - * }, - * ... - * ] - * } - * } - */ -public class Net_ListUserParams_Response extends Net_Response { - - private String login; - private List params = new ArrayList<>(); - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public List getParams() { return params; } - public void setParams(List params) { this.params = params; } - - public static class Item { - private String login; - private String param; - private Long time_ms; - private String value; - private String device_key; - private String signature; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } - - public Long getTime_ms() { return time_ms; } - public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } - - public String getValue() { return value; } - public void setValue(String value) { this.value = value; } - - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Request; - -/** - * Запрос UpsertUserParam — добавить/обновить сохранённый параметр пользователя. - * - * Клиент отправляет: - * - * { - * "op": "UpsertUserParam", - * "requestId": "req-123", - * "payload": { - * "login": "anya", - * "param": "feed:lastSeenGlobal", - * "time_ms": 1736000000123, - * "value": "105", - * "device_key": "base64-ed25519-public-key-32", - * "signature": "base64-ed25519-signature-64" - * } - * } - * - * Подпись считается от UTF-8 строки: - * USER_PARAMETER_PREFIX + login + param + time_ms + value - */ -public class Net_UpsertUserParam_Request extends Net_Request { - - private String login; - private String param; - private Long time_ms; - private String value; - - private String device_key; - private String signature; - - public String getLogin() { return login; } - public void setLogin(String login) { this.login = login; } - - public String getParam() { return param; } - public void setParam(String param) { this.param = param; } - - public Long getTime_ms() { return time_ms; } - public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } - - public String getValue() { return value; } - public void setValue(String value) { this.value = value; } - - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } - - public String getSignature() { return signature; } - public void setSignature(String signature) { this.signature = signature; } -} -package server.logic.ws_protocol.JSON.handlers.userParams.entyties; - -import server.logic.ws_protocol.JSON.entyties.Net_Response; - -/** - * Ответ на UpsertUserParam. - * - * Успех: - * { - * "op": "UpsertUserParam", - * "requestId": "req-123", - * "status": 200, - * "payload": { } - * } - */ -public class Net_UpsertUserParam_Response extends Net_Response { - // MVP: без payload. При желании позже можно добавить created/updated. -} -package server.logic.ws_protocol.JSON.handlers.userParams; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.userParams.entyties.Net_GetUserParam_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.UserParamsDAO; -import shine.db.entities.UserParamEntry; - -import java.sql.Connection; - -/** - * GetUserParam — получить один параметр пользователя. - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) запрос не ограничивает просмотр параметров. - * В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры. - * Для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_GetUserParam_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_GetUserParam_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_GetUserParam_Request req = (Net_GetUserParam_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank() - || req.getParam() == null || req.getParam().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login/param" - ); - } - - String login = req.getLogin().trim(); - String param = req.getParam().trim(); - - try { - SqliteDbController db = SqliteDbController.getInstance(); - UserParamsDAO dao = UserParamsDAO.getInstance(); - - try (Connection c = db.getConnection()) { - UserParamEntry e = dao.getByLoginAndParam(c, login, param); - - if (e == null) { - Net_GetUserParam_Response resp = new Net_GetUserParam_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(404); - return resp; - } - - Net_GetUserParam_Response resp = new Net_GetUserParam_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - resp.setLogin(e.getLogin()); - resp.setParam(e.getParam()); - resp.setTime_ms(e.getTimeMs()); - resp.setValue(e.getValue()); - resp.setDevice_key(e.getDeviceKey()); - resp.setSignature(e.getSignature()); - - return resp; - } - - } catch (Exception e) { - log.error("❌ Internal error GetUserParam", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.userParams.entyties.Net_ListUserParams_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.UserParamsDAO; -import shine.db.entities.UserParamEntry; - -import java.sql.Connection; -import java.util.ArrayList; -import java.util.List; - -/** - * ListUserParams — получить все параметры пользователя. - * - * ПРО ДОСТУП (на будущее): - * --------------------------------------------------------------------------------- - * Сейчас (MVP) запрос не ограничивает просмотр параметров. - * В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры. - * Для MVP эти проверки не нужны. - * --------------------------------------------------------------------------------- - */ -public class Net_ListUserParams_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_ListUserParams_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_ListUserParams_Request req = (Net_ListUserParams_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login" - ); - } - - String login = req.getLogin().trim(); - - try { - SqliteDbController db = SqliteDbController.getInstance(); - UserParamsDAO dao = UserParamsDAO.getInstance(); - - List entries; - try (Connection c = db.getConnection()) { - entries = dao.getByLogin(c, login); - } - - Net_ListUserParams_Response resp = new Net_ListUserParams_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - resp.setLogin(login); - - List items = new ArrayList<>(); - for (UserParamEntry e : entries) { - Net_ListUserParams_Response.Item it = new Net_ListUserParams_Response.Item(); - it.setLogin(e.getLogin()); - it.setParam(e.getParam()); - it.setTime_ms(e.getTimeMs()); - it.setValue(e.getValue()); - it.setDevice_key(e.getDeviceKey()); - it.setSignature(e.getSignature()); - items.add(it); - } - resp.setParams(items); - - return resp; - - } catch (Exception e) { - log.error("❌ Internal error ListUserParams", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} -package server.logic.ws_protocol.JSON.handlers.userParams; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.userParams.entyties.Net_UpsertUserParam_Request; -import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Response; -import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; -import server.logic.ws_protocol.WireCodes; -import shine.db.SqliteDbController; -import shine.db.dao.SolanaUsersDAO; -import shine.db.dao.UserParamsDAO; -import shine.db.entities.SolanaUserEntry; -import shine.db.entities.UserParamEntry; -import utils.config.ShineSignatureConstants; -import utils.crypto.Ed25519Util; - -import java.nio.charset.StandardCharsets; -import java.sql.Connection; -import java.sql.SQLException; -import java.util.Base64; - -/** - * Net_UpsertUserParam_Handler - * - * Делает (MVP, без "сессий"): - * 1) Проверка входных полей. - * 2) Проверка подписи Ed25519 по device_key. - * 3) Проверка, что пользователь существует и что device_key принадлежит этому login. - * 4) Атомарная запись в БД "только если time_ms новее" (UPSERT + WHERE). - * - * ВАЖНО: - * - НИКАКИХ ручных транзакций / BEGIN здесь нет. - * - autoCommit=true, каждый statement завершённый сам по себе. - * - Гонки не страшны: если за время проверок кто-то записал более новый time_ms, - * наш финальный UPSERT просто вернёт 0 обновлённых строк. - */ -public class Net_UpsertUserParam_Handler implements JsonMessageHandler { - - private static final Logger log = LoggerFactory.getLogger(Net_UpsertUserParam_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_UpsertUserParam_Request req = (Net_UpsertUserParam_Request) baseRequest; - - if (req.getLogin() == null || req.getLogin().isBlank() - || req.getParam() == null || req.getParam().isBlank() - || req.getTime_ms() == null || req.getTime_ms() <= 0 - || req.getValue() == null - || req.getDevice_key() == null || req.getDevice_key().isBlank() - || req.getSignature() == null || req.getSignature().isBlank()) { - - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_FIELDS", - "Некорректные поля: login/param/time_ms/value/device_key/signature" - ); - } - - final String login = req.getLogin().trim(); - final String param = req.getParam().trim(); - final long timeMs = req.getTime_ms(); - final String value = req.getValue(); - final String deviceKeyB64 = req.getDevice_key().trim(); - final String signatureB64 = req.getSignature().trim(); - - try { - // ---------------- Base64 decode ---------------- - byte[] pubKey32; - byte[] sig64; - try { - pubKey32 = Base64.getDecoder().decode(deviceKeyB64); - sig64 = Base64.getDecoder().decode(signatureB64); - } catch (IllegalArgumentException e) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_BASE64", - "device_key/signature должны быть Base64" - ); - } - - if (pubKey32.length != 32) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_DEVICE_KEY", - "device_key должен быть Base64(32 bytes)" - ); - } - if (sig64.length != 64) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.BAD_REQUEST, - "BAD_SIGNATURE", - "signature должна быть Base64(64 bytes)" - ); - } - - // ---------------- Signature verify ---------------- - String signText = ShineSignatureConstants.USER_PARAMETER_PREFIX - + login - + param - + timeMs - + value; - - byte[] signBytes = signText.getBytes(StandardCharsets.UTF_8); - - boolean sigOk = Ed25519Util.verify(signBytes, sig64, pubKey32); - if (!sigOk) { - return NetExceptionResponseFactory.error( - req, - 403, - "SIGNATURE_INVALID", - "Подпись не прошла проверку" - ); - } - - // ---------------- DB checks + upsert ---------------- - SqliteDbController db = SqliteDbController.getInstance(); - SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); - UserParamsDAO paramsDAO = UserParamsDAO.getInstance(); - - try (Connection c = db.getConnection()) { - // 1) user exists - SolanaUserEntry user = usersDAO.getByLogin(c, login); - if (user == null) { - return NetExceptionResponseFactory.error( - req, - 404, - "USER_NOT_FOUND", - "Пользователь не найден" - ); - } - - // 2) device key must match the user's stored deviceKey - String userDeviceKey = user.getDeviceKey(); - if (userDeviceKey == null || userDeviceKey.isBlank()) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "USER_DEVICE_KEY_EMPTY", - "У пользователя не задан deviceKey в БД" - ); - } - - if (!userDeviceKey.trim().equals(deviceKeyB64)) { - return NetExceptionResponseFactory.error( - req, - 403, - "DEVICE_KEY_MISMATCH", - "device_key не соответствует пользователю" - ); - } - - // 3) atomic upsert-if-newer - UserParamEntry e = new UserParamEntry( - login, - param, - timeMs, - value, - deviceKeyB64, - signatureB64 - ); - - int changed = paramsDAO.upsertIfNewer(c, e); - - Net_UpsertUserParam_Response resp = new Net_UpsertUserParam_Response(); - resp.setOp(req.getOp()); - resp.setRequestId(req.getRequestId()); - resp.setStatus(WireCodes.Status.OK); - - if (changed == 1) { - log.info("✅ UpsertUserParam applied: login={}, param={}, time_ms={}", login, param, timeMs); - } else { - // 0 строк — значит в БД уже есть time_ms >= incoming - log.info("ℹ️ UpsertUserParam ignored (not newer): login={}, param={}, time_ms={}", login, param, timeMs); - } - - return resp; - } - - } catch (SQLException e) { - log.error("❌ DB error UpsertUserParam", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.SERVER_DATA_ERROR, - "DB_ERROR", - "Ошибка БД" - ); - } catch (Exception e) { - log.error("❌ Internal error UpsertUserParam", e); - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.INTERNAL_ERROR, - "INTERNAL_ERROR", - "Внутренняя ошибка сервера" - ); - } - } -} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/concat_to_file.sh b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/concat_to_file.sh deleted file mode 100755 index f6db1f1..0000000 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/concat_to_file.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# собрать только *.java файлы и вывести их содержимое в файл -find . -type f -name "*.java" | sort | while read -r f; do - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Все .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/entyties/Net_GetUserParam_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/entyties/Net_GetUserParam_Response.java index 77e5625..e5cb295 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/entyties/Net_GetUserParam_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/entyties/Net_GetUserParam_Response.java @@ -15,7 +15,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response; * "param": "feed:lastSeenGlobal", * "time_ms": 1736000000123, * "value": "105", - * "device_key": "base64-32", + * "client_key": "base64-32", * "signature": "base64-64" * } * } @@ -29,7 +29,7 @@ public class Net_GetUserParam_Response extends Net_Response { private String param; private Long time_ms; private String value; - private String device_key; + private String client_key; private String signature; public String getLogin() { return login; } @@ -44,8 +44,8 @@ public class Net_GetUserParam_Response extends Net_Response { public String getValue() { return value; } public void setValue(String value) { this.value = value; } - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } + public String getClient_key() { return client_key; } + public void setClient_key(String client_key) { this.client_key = client_key; } public String getSignature() { return signature; } public void setSignature(String signature) { this.signature = signature; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/entyties/Net_ListUserParams_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/entyties/Net_ListUserParams_Response.java index 75e06fc..0b6e703 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/entyties/Net_ListUserParams_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/entyties/Net_ListUserParams_Response.java @@ -20,7 +20,7 @@ import java.util.List; * "param": "feed:lastSeenGlobal", * "time_ms": 1736000000123, * "value": "105", - * "device_key": "base64-32", + * "client_key": "base64-32", * "signature": "base64-64" * }, * ... @@ -44,7 +44,7 @@ public class Net_ListUserParams_Response extends Net_Response { private String param; private Long time_ms; private String value; - private String device_key; + private String client_key; private String signature; public String getLogin() { return login; } @@ -59,8 +59,8 @@ public class Net_ListUserParams_Response extends Net_Response { public String getValue() { return value; } public void setValue(String value) { this.value = value; } - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } + public String getClient_key() { return client_key; } + public void setClient_key(String client_key) { this.client_key = client_key; } public String getSignature() { return signature; } public void setSignature(String signature) { this.signature = signature; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/entyties/Net_UpsertUserParam_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/entyties/Net_UpsertUserParam_Request.java index ed1c7ff..4d8c159 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/entyties/Net_UpsertUserParam_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userParams/entyties/Net_UpsertUserParam_Request.java @@ -15,7 +15,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request; * "param": "feed:lastSeenGlobal", * "time_ms": 1736000000123, * "value": "105", - * "device_key": "base64-ed25519-public-key-32", + * "client_key": "base64-ed25519-public-key-32", * "signature": "base64-ed25519-signature-64" * } * } @@ -30,7 +30,7 @@ public class Net_UpsertUserParam_Request extends Net_Request { private Long time_ms; private String value; - private String device_key; + private String client_key; private String signature; public String getLogin() { return login; } @@ -45,8 +45,8 @@ public class Net_UpsertUserParam_Request extends Net_Request { public String getValue() { return value; } public void setValue(String value) { this.value = value; } - public String getDevice_key() { return device_key; } - public void setDevice_key(String device_key) { this.device_key = device_key; } + public String getClient_key() { return client_key; } + public void setClient_key(String client_key) { this.client_key = client_key; } public String getSignature() { return signature; } public void setSignature(String signature) { this.signature = signature; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java index 435d915..353f335 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendDirectMessage_Handler.java @@ -62,9 +62,9 @@ public class Net_SendDirectMessage_Handler implements JsonMessageHandler { byte[] publicKey32; try { - publicKey32 = Ed25519Util.keyFromBase64(fromUser.getDeviceKey()); + publicKey32 = Ed25519Util.keyFromBase64(fromUser.getClientKey()); } catch (Exception e) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_DEVICE_KEY", "Некорректный deviceKey отправителя"); + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_DEVICE_KEY", "Некорректный clientKey отправителя"); } if (!Ed25519Util.verify(packet.signedBody, packet.signature64, publicKey32)) { return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_SIGNATURE", "Подпись не прошла проверку"); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java index 4e2bf93..0030ba7 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java @@ -44,7 +44,7 @@ final class SignedMessagesCore { if (from == null || to == null) { throw new IllegalArgumentException("USER_NOT_FOUND"); } - byte[] pubKey32 = Ed25519Util.keyFromBase64(from.getDeviceKey()); + byte[] pubKey32 = Ed25519Util.keyFromBase64(from.getClientKey()); if (!Ed25519Util.verify(block.signedBody, block.signature64, pubKey32)) { throw new IllegalArgumentException("BAD_SIGNATURE"); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/AuthSignatures.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/AuthSignatures.java index be3c997..539ab1e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/AuthSignatures.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/utils/AuthSignatures.java @@ -31,7 +31,7 @@ // } // // /** -// * Проверка подписи CreateAuthSession(v2) по deviceKey пользователя. +// * Проверка подписи CreateAuthSession(v2) по clientKey пользователя. // * Подпись проверяется над preimageCreateAuthSession(...). // */ // public static boolean verifyCreateAuthSessionSignature( @@ -42,8 +42,8 @@ // String signatureB64 // ) throws IllegalArgumentException { // -// // user.getDeviceKey() — base64 публичного ключа (32 байта) -// byte[] publicKey32 = decodeBase64Any(user.getDeviceKey()); +// // user.getClientKey() — base64 публичного ключа (32 байта) +// byte[] publicKey32 = decodeBase64Any(user.getClientKey()); // byte[] signature64 = decodeBase64Any(signatureB64); // // byte[] preimage = preimageCreateAuthSession(login, timeMs, authNonce); diff --git a/SHiNE-server/src/main/all_files.txt b/SHiNE-server/src/main/all_files.txt deleted file mode 100644 index 83d0c14..0000000 --- a/SHiNE-server/src/main/all_files.txt +++ /dev/null @@ -1,552 +0,0 @@ -package server.logic; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.ws_protocol.binary.handlers.*; -import server.logic.ws_protocol.WireCodes; - -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.util.Map; - -/** - * Обработчик входящих сообщение на сервер. - * По коду сообщения (первые 4 байта сообщения) находи нужный хэндлер и передаёт в него сообщение - * Получает и возвращает ответ от хэндлера - */ -public final class InboundMessageProcessor { - private static final Logger log = LoggerFactory.getLogger(InboundMessageProcessor.class); - - private static final Map HANDLERS = Map.of( -// WireCodes.Op.PING, new PingHandler() -// WireCodes.Op.ADD_BLOCK, new AddBlockHandler(), -// WireCodes.Op.GET_BLOCKCHAIN,new GetBlockchainHandler() -// WireCodes.Op.SEARCH_USERS, new SearchUsersHandler(), -// WireCodes.Op.GET_LAST_BLOCK_INFO,new GetLastBlockInfoHandler() - - ); - - private InboundMessageProcessor() {} - - public static byte[] process(byte[] msg) { - if (msg == null || msg.length < 4) - return intTo4Bytes(WireCodes.Status.BAD_REQUEST); - - int op = first4ToInt(msg); - MessageHandler h = HANDLERS.get(op); - if (h == null) { - log.warn("Неизвестная операция: {}", op); - return intTo4Bytes(WireCodes.Status.BAD_REQUEST); - } - - try { - return h.handle(msg); - } catch (Exception e) { - log.error("Ошибка при обработке операции {}", op, e); - return intTo4Bytes(WireCodes.Status.INTERNAL_ERROR); - } - } - - private static int first4ToInt(byte[] msg) { - return ByteBuffer.wrap(msg, 0, 4) - .order(ByteOrder.BIG_ENDIAN) - .getInt(); - } - - public static byte[] intTo4Bytes(int code) { - return ByteBuffer.allocate(4) - .order(ByteOrder.BIG_ENDIAN) - .putInt(code) - .array(); - } - - - -} - - -package server.logic.ws_protocol.binary.handlers; - -/** - * Общий интерфейс для всех обработчиков входящих сообщений. - */ -public interface MessageHandler { - /** - * Обработать входящее сообщение и вернуть бинарный ответ. - */ - byte[] handle(byte[] msg); -} - -package server.ws; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import shine.db.dao.BlockchainStateDAO; -import shine.db.entities.BlockchainStateEntry; -import utils.files.FileStoreUtil; -import shine.log.BlockchainAdminNotifier; - -import java.io.IOException; -import java.nio.file.*; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.List; - -/** - * =============================================================== - * BlockchainTmpRecoveryOnStartup — восстановление консистентности - * blockchain файлов при старте сервера. - * - * Сценарий проблемы: - * - при добавлении блока сначала пишется .tmp_bch - * - потом коммитится БД (state.fileSizeBytes) - * - потом tmp переименовывается поверх .bch (атомарно, если возможно) - * - * Если сервер упал в середине, может остаться tmp: - * - tmp есть, а основной .bch остался старым - * - tmp есть, а основной .bch уже удалили/заменить не успели - * - tmp есть, а БД успела/не успела обновиться - * - * Этот класс при старте: - * - ищет все *.tmp_bch в data/ - * - сравнивает размеры: - * - tmp - * - main (если есть) - * - state.fileSizeBytes (если есть) - * - * Правила: - * - * A) state есть: - * - если stateSize == mainSize => tmp удаляем - * - если stateSize == tmpSize => tmp ставим на место main (atomicReplaceBlockchainFile) - * - иначе => КРИТИЧЕСКАЯ ОШИБКА: сервер останавливаем + уведомление администратору - * - * B) state НЕТ: - * - если main НЕТ и tmp ЕСТЬ => tmp удаляем (мусор после падения/неуспешной транзакции) - * - если main ЕСТЬ и tmp ЕСТЬ => КРИТИЧЕСКАЯ ОШИБКА: уведомление администратору + стоп сервера - * - * Логирование: - * - обо всех восстановленных/удалённых tmp пишем в лог - * - если tmp-файлов нет — тоже пишем в лог - * =============================================================== - */ -public final class BlockchainTmpRecoveryOnStartup { - - private static final Logger log = LoggerFactory.getLogger(BlockchainTmpRecoveryOnStartup.class); - - private BlockchainTmpRecoveryOnStartup() {} - - /** - * Запуск восстановления. - * Если обнаружена ситуация, когда размеры не совпали и сервер сам не может чинить — бросаем исключение. - */ - public static void runRecoveryOrThrow() { - FileStoreUtil fs = FileStoreUtil.getInstance(); - BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); - - Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME); - ensureDirExists(dataDir); - - List tmpFiles = listTmpFiles(dataDir); - - if (tmpFiles.isEmpty()) { - log.info("🟢 BlockchainTmpRecovery: временных *.tmp_bch файлов не найдено — восстановление не требуется."); - return; - } - - log.warn("🟡 BlockchainTmpRecovery: найдено временных файлов: {}", tmpFiles.size()); - - for (Path tmpPath : tmpFiles) { - String fileName = tmpPath.getFileName().toString(); - String blockchainName = extractBlockchainNameFromTmp(fileName); - - if (blockchainName == null || blockchainName.isBlank()) { - // странное имя — не трогаем автоматически, но это уже повод дернуть админа - BlockchainAdminNotifier.critical( - "НАЙДЕН TMP-ФАЙЛ С НЕОЖИДАННЫМ ИМЕНЕМ: " + fileName + " (не могу определить blockchainName).", - null - ); - throw new IllegalStateException("Bad tmp file name: " + fileName); - } - - Path mainPath = dataDir.resolve(fs.buildBlockchainFileName(blockchainName)); - - long tmpSize = safeSize(tmpPath); - boolean mainExists = Files.exists(mainPath); - long mainSize = mainExists ? safeSize(mainPath) : -1L; - - BlockchainStateEntry st = null; - try { - st = stateDAO.getByBlockchainName(blockchainName); - } catch (SQLException e) { - BlockchainAdminNotifier.critical( - "ОШИБКА БД ПРИ ВОССТАНОВЛЕНИИ TMP: blockchainName=" + blockchainName + " (сервер остановлен).", - e - ); - throw new IllegalStateException("DB error during tmp recovery for " + blockchainName, e); - } - - // ============================================================ - // CASE B) state НЕТ - // ============================================================ - if (st == null) { - - if (!mainExists) { - // НЕТ state, НЕТ main, есть tmp => удаляем tmp - log.warn("🟠 BlockchainTmpRecovery: state отсутствует и main отсутствует, но tmp найден => удаляем tmp. blockchainName={}, tmpSize={}", - blockchainName, tmpSize); - safeDelete(tmpPath); - continue; - } - - // НЕТ state, но main есть и tmp есть => это уже подозрительно - BlockchainAdminNotifier.critical( - "НЕСОГЛАСОВАННОСТЬ: ЕСТЬ main И tmp, НО НЕТ state В БД. " + - "blockchainName=" + blockchainName + - ", mainSize=" + mainSize + - ", tmpSize=" + tmpSize + - ". СЕРВЕР ОСТАНОВЛЕН. " + - "ПОДОЗРЕНИЕ: файлы могли быть изменены вне сервера.", - null - ); - throw new IllegalStateException("State missing but both main and tmp exist for " + blockchainName); - } - - // ============================================================ - // CASE A) state ЕСТЬ - // ============================================================ - long stateSize = st.getFileSizeBytes(); - - // 1) stateSize == mainSize => tmp мусор - if (mainExists && mainSize == stateSize) { - log.info("🟢 BlockchainTmpRecovery: stateSize совпадает с main => tmp удаляем. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}", - blockchainName, stateSize, mainSize, tmpSize); - safeDelete(tmpPath); - continue; - } - - // 2) stateSize == tmpSize => tmp это актуальная версия, ставим на место main - if (tmpSize == stateSize) { - log.warn("🟡 BlockchainTmpRecovery: stateSize совпадает с tmp => восстанавливаем main из tmp. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}", - blockchainName, stateSize, mainSize, tmpSize); - - try { - // метод уже есть и делает move tmp->main с попыткой ATOMIC_MOVE - fs.atomicReplaceBlockchainFile(blockchainName); - - // после move tmp должен исчезнуть сам (перемещён) - log.info("✅ BlockchainTmpRecovery: восстановление выполнено. blockchainName={}, newMainSize={}", - blockchainName, safeSize(mainPath)); - - } catch (Exception e) { - BlockchainAdminNotifier.critical( - "НЕ УДАЛОСЬ ВОССТАНОВИТЬ main ИЗ tmp (move failed). " + - "blockchainName=" + blockchainName + - ", stateSize=" + stateSize + - ", mainSize=" + mainSize + - ", tmpSize=" + tmpSize + - ". СЕРВЕР ОСТАНОВЛЕН.", - e - ); - throw new IllegalStateException("Cannot replace main from tmp for " + blockchainName, e); - } - continue; - } - - // 3) НИЧЕГО НЕ СОВПАЛО => критическая ситуация - BlockchainAdminNotifier.critical( - "ФАТАЛЬНАЯ НЕСОГЛАСОВАННОСТЬ BLOCKCHAIN ФАЙЛОВ. " + - "blockchainName=" + blockchainName + - ", stateSize=" + stateSize + - ", mainExists=" + mainExists + - ", mainSize=" + mainSize + - ", tmpSize=" + tmpSize + - ". СЕРВЕР ОСТАНОВЛЕН. " + - "ТУТ НУЖНО УВЕДОМЛЕНИЕ АДМИНИСТРАТОРУ: возможно файлы изменены вручную/другой программой.", - null - ); - throw new IllegalStateException("Blockchain files mismatch for " + blockchainName); - } - - log.info("✅ BlockchainTmpRecovery: обработка tmp-файлов завершена."); - } - - /* ===================================================================== */ - /* =============================== Helpers ============================== */ - /* ===================================================================== */ - - private static void ensureDirExists(Path dir) { - try { - if (!Files.exists(dir)) { - Files.createDirectories(dir); - } - } catch (IOException e) { - throw new IllegalStateException("Cannot create data dir: " + dir, e); - } - } - - private static List listTmpFiles(Path dataDir) { - List out = new ArrayList<>(); - try (DirectoryStream ds = Files.newDirectoryStream(dataDir, "*" + FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) { - for (Path p : ds) { - if (Files.isRegularFile(p)) out.add(p); - } - } catch (IOException e) { - throw new IllegalStateException("Cannot list tmp files in: " + dataDir, e); - } - return out; - } - - /** - * Из "anya0001.tmp_bch" -> "anya0001" - */ - private static String extractBlockchainNameFromTmp(String tmpFileName) { - if (tmpFileName == null) return null; - if (!tmpFileName.endsWith(FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) return null; - - String base = tmpFileName.substring(0, tmpFileName.length() - FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION.length()); - - // базовая защита: не допускаем слэши/.. даже если кто-то подложил файл - if (base.isBlank()) return null; - if (base.contains("/") || base.contains("\\") || base.contains("..")) return null; - - return base; - } - - private static long safeSize(Path p) { - try { - return Files.size(p); - } catch (IOException e) { - throw new IllegalStateException("Cannot read file size: " + p, e); - } - } - - private static void safeDelete(Path p) { - try { - Files.deleteIfExists(p); - } catch (IOException e) { - throw new IllegalStateException("Cannot delete file: " + p, e); - } - } -} -package server.ws; - -import org.eclipse.jetty.websocket.api.Session; -import org.eclipse.jetty.websocket.api.WriteCallback; -import org.eclipse.jetty.websocket.api.annotations.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import server.logic.InboundMessageProcessor; -import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; -import server.logic.ws_protocol.JSON.ConnectionContext; -import server.logic.ws_protocol.JSON.JsonInboundProcessor; - -import java.nio.ByteBuffer; -import java.util.concurrent.CompletableFuture; - -@WebSocket -public class BlockchainWsEndpoint { - private static final Logger log = LoggerFactory.getLogger(BlockchainWsEndpoint.class); - - private Session session; - - /** Контекст для текущего WebSocket-соединения. */ - private final ConnectionContext connectionContext = new ConnectionContext(); - - @OnWebSocketConnect - public void onConnect(Session session) { - this.session = session; - // Привязываем WebSocket-сессию к ConnectionContext - connectionContext.setWsSession(session); - log.info("WS connected: {}", session.getRemoteAddress()); - } - - @OnWebSocketMessage - public void onBinary(byte[] payload, int offset, int length) { - byte[] msg = new byte[length]; - System.arraycopy(payload, offset, msg, 0, length); - - // Асинхронно обрабатываем входящее бинарное сообщение - CompletableFuture - .supplyAsync(() -> InboundMessageProcessor.process(msg)) - .thenAccept(resp -> { - if (resp != null && session != null && session.isOpen()) { - session.getRemote().sendBytes(ByteBuffer.wrap(resp), new WriteCallback() { - @Override - public void writeFailed(Throwable x) { - log.warn("Failed to send response", x); - } - - @Override - public void writeSuccess() { - log.debug("Response sent successfully"); - } - }); - } - }) - .exceptionally(ex -> { - log.error("Processing failed", ex); - trySendCode(500); - return null; - }); - } - - private void trySendCode(int code) { - if (session != null && session.isOpen()) { - byte[] resp = InboundMessageProcessor.intTo4Bytes(code); - session.getRemote().sendBytes(ByteBuffer.wrap(resp), new WriteCallback() { - @Override - public void writeFailed(Throwable x) { - log.warn("Failed to send error code", x); - } - - @Override - public void writeSuccess() { - log.debug("Error code {} sent", code); - } - }); - } - } - - @OnWebSocketClose - public void onClose(int statusCode, String reason) { - log.info("WS closed: {} {}", statusCode, reason); - // Удаляем это подключение из реестра активных соединений - ActiveConnectionsRegistry.getInstance().remove(connectionContext); - // На всякий случай очищаем контекст - connectionContext.reset(); - } - - @OnWebSocketError - public void onError(Throwable cause) { - log.error("WS error", cause); - } - - // Обработка текстовых JSON-запросов - @OnWebSocketMessage - public void onText(String message) { - log.info("📥 Получено TEXT-сообщение от клиента: {}", message); - - CompletableFuture - .supplyAsync(() -> JsonInboundProcessor.processJson(message, connectionContext)) - .thenAccept(respJson -> { - if (respJson != null && session != null && session.isOpen()) { - - log.info("📤 Отправляем ответ клиенту: {}", respJson); - - session.getRemote().sendString(respJson, new WriteCallback() { - @Override - public void writeFailed(Throwable x) { - log.warn("⚠️ Не удалось отправить JSON-ответ клиенту: {}", x.toString()); - } - - @Override - public void writeSuccess() { - log.debug("✔ JSON-ответ успешно отправлен"); - } - }); - } - }) - .exceptionally(ex -> { - log.error("❌ Ошибка при обработке JSON-сообщения", ex); - trySendJsonError(); - return null; - }); - } - - private void trySendJsonError() { - if (session != null && session.isOpen()) { - String resp = "{\"op\":null,\"requestId\":null,\"status\":500," - + "\"payload\":{\"code\":\"INTERNAL_ERROR\",\"message\":\"Ошибка сервера\"}}"; - - log.info("📤 Отправляем клиенту ошибку JSON: {}", resp); - - session.getRemote().sendString(resp, new WriteCallback() { - @Override - public void writeFailed(Throwable x) { - log.warn("⚠️ Не удалось отправить JSON-ответ клиенту: {}", x.toString()); - } - - @Override - public void writeSuccess() { - log.debug("✔ JSON-ошибка успешно отправлена"); - } - }); - } - } -} - -package server.ws; - -import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import utils.config.AppConfig; - -import java.time.Duration; - -/** - * WsServer — поднимает Jetty WS на /ws. - * - * ВАЖНО: - * - перед стартом сервера выполняем recovery tmp-блокчейнов. - * - если обнаружена несогласованность, которую сервер сам чинить не может — - * recovery бросает исключение и сервер не стартует. - */ -public final class WsServer { - - private static final Logger log = LoggerFactory.getLogger(WsServer.class); - - public static void main(String[] args) throws Exception { - - // ============================================================ - // 0) Восстановление консистентности blockchain файлов - // ============================================================ - try { - BlockchainTmpRecoveryOnStartup.runRecoveryOrThrow(); - } catch (Exception e) { - // Уже должно быть “большое” уведомление через BlockchainAdminNotifier, - // но на всякий случай логируем ещё раз. - log.error("❌ Сервер НЕ будет запущен: критическая ошибка восстановления blockchain tmp-файлов.", e); - throw e; // останавливаем запуск - } - - // ============================================================ - // 1) Настройки порта - // ============================================================ - AppConfig config = AppConfig.getInstance(); - int port = 7070; - try { - String portStr = config.getParam("server.port"); - if (portStr != null && !portStr.isBlank()) { - port = Integer.parseInt(portStr.trim()); - } - } catch (Exception e) { - log.info("Не удалось прочитать параметр server.port, используем порт по умолчанию {}", port); - } - - // ============================================================ - // 2) Запуск Jetty WS - // ============================================================ - Server server = new Server(port); - - ServletContextHandler context = new ServletContextHandler(); - context.setContextPath("/"); - server.setHandler(context); - - // Инициализация контейнера WebSocket - JettyWebSocketServletContainerInitializer.configure(context, (servletContext, wsContainer) -> { - // Таймаут простоя соединения (Jetty 11 синтаксис) - wsContainer.setIdleTimeout(Duration.ofMinutes(5)); - - // Маппинг эндпоинта - wsContainer.addMapping("/ws", (req, resp) -> new BlockchainWsEndpoint()); - }); - - server.start(); - log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port); - server.join(); - } -} diff --git a/SHiNE-server/src/main/concat_to_file.sh b/SHiNE-server/src/main/concat_to_file.sh deleted file mode 100755 index f6db1f1..0000000 --- a/SHiNE-server/src/main/concat_to_file.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# собрать только *.java файлы и вывести их содержимое в файл -find . -type f -name "*.java" | sort | while read -r f; do - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Все .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/SHiNE-server/src/main/concat_to_file2.sh b/SHiNE-server/src/main/concat_to_file2.sh deleted file mode 100755 index dc5f5d1..0000000 --- a/SHiNE-server/src/main/concat_to_file2.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" -SKIPFILE="skip.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# читаем список исключённых имён (без расширения) в массив -if [[ -f "$SKIPFILE" ]]; then - mapfile -t SKIP_LIST < "$SKIPFILE" -else - SKIP_LIST=() -fi - -find . -type f -name "*.java" | sort | while read -r f; do - fname=$(basename "$f" .java) # имя файла без расширения - - # проверяем, есть ли имя в списке исключений - skip=false - for skipf in "${SKIP_LIST[@]}"; do - if [[ "$fname" == "$skipf" ]]; then - skip=true - break - fi - done - - if [[ "$skip" == true ]]; then - echo "Пропускаем $f" - continue - fi - - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -echo "Готово! Все .java файлы собраны в $OUTFILE (кроме исключённых из $SKIPFILE)" diff --git a/SHiNE-server/src/main/запросы.sh b/SHiNE-server/src/main/запросы.sh deleted file mode 100644 index 02c8a81..0000000 --- a/SHiNE-server/src/main/запросы.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# OUTFILE: -# - если пустая строка ("") -> в файл НЕ пишем, только в буфер -# - если не пустая -> пишем в файл + (если есть wl-copy) копируем в буфер -OUTFILE="all_files.txt" -# OUTFILE="" - -# === НАСТРОЙКА: перечисляй тут пути (каталоги и/или конкретные файлы) === -# - Если путь указывает на ФАЙЛ: берём его ВСЕГДА, даже если это не .java -# - Если путь указывает на КАТАЛОГ: рекурсивно берём только *.java внутри -# - Пустые строки игнорируются -TARGETS=( - #"./src/main/java" -# "./server" -# /home/ai/work/SHiNE/SHiNE-server/shine-server-blockchain - "/home/ai/work/SHiNE/SHiNE-server/shine-server-blockchain" - "/home/ai/work/SHiNE/SHiNE-server/shine-server-db" -) - -RED=$'\033[0;31m' -RESET=$'\033[0m' - -warn_red() { - echo "${RED}WARN:${RESET} $*" >&2 -} - -# временные файлы -TMP_LIST="$(mktemp)" -TMP_OUT="$(mktemp)" -trap 'rm -f "$TMP_LIST" "$TMP_OUT"' EXIT - -# собрать пути -for path in "${TARGETS[@]}"; do - path="$(printf '%s' "$path" | sed -e 's/^[[:space:]]\+//' -e 's/[[:space:]]\+$//')" - [[ -z "$path" ]] && continue - - if [[ -f "$path" ]]; then - printf '%s\n' "$path" >> "$TMP_LIST" - elif [[ -d "$path" ]]; then - find "$path" -type f -name "*.java" >> "$TMP_LIST" - else - warn_red "Не найдено (пропускаю): $path" - fi -done - -# склеиваем в TMP_OUT -sort -u "$TMP_LIST" | while IFS= read -r f; do - if [[ ! -f "$f" ]]; then - warn_red "Файл исчез (пропускаю): $f" - continue - fi - cat "$f" >> "$TMP_OUT" - echo >> "$TMP_OUT" -done - -# если OUTFILE не пуст — пишем файл -if [[ -n "${OUTFILE:-}" ]]; then - : > "$OUTFILE" - cat "$TMP_OUT" > "$OUTFILE" -fi - -# копирование в буфер (Wayland), если доступно -if command -v wl-copy >/dev/null 2>&1; then - wl-copy < "$TMP_OUT" -else - warn_red "wl-copy не найден — в буфер не скопировано." -fi - -echo "Готово!" -if [[ -n "${OUTFILE:-}" ]]; then - echo "Все файлы собраны в $OUTFILE" -else - echo "OUTFILE пуст — в файл не писали, только буфер (если wl-copy доступен)" -fi diff --git a/SHiNE-server/src/test/addblocks.sh b/SHiNE-server/src/test/addblocks.sh deleted file mode 100755 index 5f8f10c..0000000 --- a/SHiNE-server/src/test/addblocks.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# === Список файлов (ТОЛЬКО имена без расширений) === -# пример: Main значит Main.java, Utils значит Utils.java -NAMES=( - "IT_04_UserParams_NoAuth" - "AddBlockSender" - "ChainState" - "JsonBuilders" -) - -# очищаем или создаём файл -: > "$OUTFILE" - -# Быстрый фильтр: сделаем хеш-таблицу из имён (ассоц. массив) -declare -A WANT=() -for name in "${NAMES[@]}"; do - WANT["$name"]=1 -done - -# собрать только нужные *.java по базовому имени -find . -type f -name "*.java" | sort | while read -r f; do - base="$(basename "$f" .java)" - if [[ -n "${WANT[$base]+x}" ]]; then - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель - fi -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Выбрано имён: ${#NAMES[@]}" -echo "Все нужные .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/SHiNE-server/src/test/all_files.txt b/SHiNE-server/src/test/all_files.txt deleted file mode 100644 index ebc0734..0000000 --- a/SHiNE-server/src/test/all_files.txt +++ /dev/null @@ -1,2951 +0,0 @@ -package test.it.blockchain; - -import blockchain.BchBlockEntry; -import blockchain.body.*; -import test.it.utils.TestConfig; -import test.it.utils.TestIds; -import test.it.utils.json.JsonParsers; -import test.it.utils.log.TestLog; -import test.it.utils.ws.WsSession; - -import java.time.Duration; -import java.util.Base64; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - -/** - * AddBlockSender — под новый формат BchBlockEntry (Frame v0): - * - blockBytes = preimage + sigMarker(2) + signature64 - * - preimage начинается с frameCode(2) = 0x0000 - * - hash32 вычисляется как sha256(preimage) - * - signature = Ed25519.sign(hash32) - * - * ВАЖНО: - * - Линии по ТЗ ведём на стороне сервера/БД (триггеры), а в тестах считаем локально. - */ -public final class AddBlockSender { - - private static final String ZERO64 = "0".repeat(64); - - private final WsSession ws; - private final ChainState state; - - private final String login; - private final String blockchainName; - private final byte[] loginPrivKey; - - public AddBlockSender(WsSession ws, ChainState state, String login, String blockchainName, byte[] loginPrivKey) { - this.ws = ws; - this.state = state; - this.login = login; - this.blockchainName = blockchainName; - this.loginPrivKey = (loginPrivKey == null ? null : loginPrivKey.clone()); - if (this.ws == null) throw new IllegalArgumentException("ws == null"); - if (this.state == null) throw new IllegalArgumentException("state == null"); - if (this.loginPrivKey == null) throw new IllegalArgumentException("loginPrivKey == null"); - } - - public ChainState state() { return state; } - - public void send(BodyRecord body, Duration timeout) { - if (body == null) throw new IllegalArgumentException("body == null"); - - body.check(); - - boolean isHeader = (body instanceof HeaderBody); - - if (isHeader) { - if (state.lastBlockNumber() != -1) { - throw new IllegalStateException("HEADER должен быть первым: lastBlockNumber уже " + state.lastBlockNumber()); - } - } else { - if (!state.hasHeader()) { - throw new IllegalStateException("Нельзя слать блоки до HEADER (нет headerHash32)"); - } - } - - int blockNumber = state.nextBlockNumber(); - byte[] prevHash32 = state.prevHash32ForNext(); - long tsSec = System.currentTimeMillis() / 1000L; - - short type = typeOf(body); - short subType = subTypeOf(body); - short version = versionOf(body); - - byte[] bodyBytes = body.toBytes(); - - // ВАЖНО: preimage должен быть БАЙТ-В-БАЙТ таким же, как в BchBlockEntry - byte[] preimage = buildPreimage(prevHash32, blockNumber, tsSec, type, subType, version, bodyBytes); - - byte[] hash32 = blockchain.BchCryptoVerifier.sha256(preimage); - byte[] signature64 = utils.crypto.Ed25519Util.sign(hash32, loginPrivKey); - - BchBlockEntry entry = new BchBlockEntry( - prevHash32, - blockNumber, - tsSec, - type, - subType, - version, - bodyBytes, - signature64 - ); - - String prevHashHexForReq = (blockNumber == 0) ? ZERO64 : state.lastBlockHashHex(); - - String reqJson = buildAddBlockJson(blockchainName, blockNumber, prevHashHexForReq, base64(entry.toBytes())); - String op = "AddBlock(user=" + login + ", block=" + blockNumber + ", type=" + (type & 0xFFFF) + ", sub=" + (subType & 0xFFFF) + ")"; - - String resp = ws.call(op, reqJson, timeout); - - assert200(op, resp); - - String serverLastHash = JsonMini.extractPayloadString(resp, "serverLastBlockHash"); - if (serverLastHash == null) { - serverLastHash = JsonMini.extractPayloadString(resp, "serverLastGlobalHash"); - } - - assertNotNull(serverLastHash, op + ": payload.serverLastBlockHash must not be null"); - assertEquals(64, serverLastHash.trim().length(), op + ": serverLastBlockHash must be 64 hex chars"); - - String localHashHex = bytesToHex64(entry.getHash32()); - - if (TestConfig.DEBUG()) { - TestLog.info(op + ": localHash=" + localHashHex); - TestLog.info(op + ": serverLastBlockHash=" + serverLastHash); - } - - assertEquals(localHashHex, serverLastHash, op + ": serverLastBlockHash must match local hash"); - - state.applyAppendedBlock(blockNumber, entry.getHash32(), isHeader, type, body); - - if (TestConfig.DEBUG()) TestLog.info(op + ": state updated"); - } - - // ---------- request JSON ---------- - - private static String buildAddBlockJson(String blockchainName, int blockNumber, String prevBlockHashHex, String blockBytesB64) { - String requestId = TestIds.next("addblock"); - return """ - { - "op": "AddBlock", - "requestId": "%s", - "payload": { - "blockchainName": "%s", - "blockNumber": %d, - "prevBlockHash": "%s", - "blockBytesB64": "%s" - } - } - """.formatted(requestId, blockchainName, blockNumber, prevBlockHashHex, blockBytesB64); - } - - private static void assert200(String op, String resp) { - int st = JsonParsers.status(resp); - assertEquals(200, st, op + ": expected status=200, but got=" + st + ", resp=" + resp); - TestLog.ok(op + ": status=200"); - } - - private static String base64(byte[] bytes) { - return Base64.getEncoder().encodeToString(bytes); - } - - private static String bytesToHex64(byte[] b32) { - char[] out = new char[64]; - final char[] HEX = "0123456789abcdef".toCharArray(); - for (int i = 0; i < 32; i++) { - int v = b32[i] & 0xFF; - out[i * 2] = HEX[v >>> 4]; - out[i * 2 + 1] = HEX[v & 0x0F]; - } - return new String(out); - } - - // ---------- header extraction from body ---------- - - private static short typeOf(BodyRecord body) { - if (body instanceof HeaderBody) return HeaderBody.TYPE; - if (body instanceof CreateChannelBody) return CreateChannelBody.TYPE; - if (body instanceof TextBody) return TextBody.TYPE; - if (body instanceof ReactionBody) return ReactionBody.TYPE; - if (body instanceof ConnectionBody) return ConnectionBody.TYPE; - if (body instanceof UserParamBody) return UserParamBody.TYPE; - throw new IllegalArgumentException("Unknown body class: " + body.getClass()); - } - - private static short subTypeOf(BodyRecord body) { - if (body instanceof HeaderBody hb) return hb.subType; - if (body instanceof CreateChannelBody cb) return cb.subType; - if (body instanceof TextBody tb) return tb.subType; - if (body instanceof ReactionBody rb) return rb.subType; - if (body instanceof ConnectionBody cb) return cb.subType; - if (body instanceof UserParamBody ub) return ub.subType; - throw new IllegalArgumentException("Unknown body class: " + body.getClass()); - } - - private static short versionOf(BodyRecord body) { - if (body instanceof HeaderBody hb) return hb.version; - if (body instanceof CreateChannelBody cb) return cb.version; - if (body instanceof TextBody tb) return tb.version; - if (body instanceof ReactionBody rb) return rb.version; - if (body instanceof ConnectionBody cb) return cb.version; - if (body instanceof UserParamBody ub) return ub.version; - throw new IllegalArgumentException("Unknown body class: " + body.getClass()); - } - - // ---------- preimage builder (строго по BchBlockEntry Frame v0) ---------- - - private static byte[] buildPreimage(byte[] prevHash32, - int blockNumber, - long tsSec, - short type, - short subType, - short version, - byte[] bodyBytes) { - - if (prevHash32 == null || prevHash32.length != 32) { - throw new IllegalArgumentException("prevHash32 must be 32 bytes"); - } - - int bodyLen = (bodyBytes == null ? 0 : bodyBytes.length); - int blockSize = BchBlockEntry.PREIMAGE_HEADER_SIZE + bodyLen; - - java.nio.ByteBuffer bb = java.nio.ByteBuffer.allocate(blockSize).order(java.nio.ByteOrder.BIG_ENDIAN); - - // [2] frameCode (v0) - bb.putShort((short) (BchBlockEntry.FRAME_CODE_V0 & 0xFFFF)); - - // [32] prevHash32 - bb.put(prevHash32); - - // [4] blockSize (preimage size) - bb.putInt(blockSize); - - // [4] blockNumber - bb.putInt(blockNumber); - - // [8] timestamp - bb.putLong(tsSec); - - // [2][2][2] type/subType/version - bb.putShort(type); - bb.putShort(subType); - bb.putShort(version); - - // [N] bodyBytes - if (bodyBytes != null) bb.put(bodyBytes); - - return bb.array(); - } -} -package test.it.blockchain; - -import blockchain.body.BodyRecord; -import blockchain.body.BodyHasLine; -import blockchain.body.CreateChannelBody; -import blockchain.body.TextBody; - -import java.util.HashMap; -import java.util.Map; - -/** - * ChainState — состояние глобальной цепочки + состояние линий. - * - * Глобальная цепочка: - * - lastBlockNumber / lastBlockHashHex - * - map blockNumber -> hash32 - * - * Линии: - * - TECH (type=0): только CREATE_CHANNEL (hasLine), root = HEADER - * - TEXT (type=1): линии каналов, root = HEADER (канал "0") или CREATE_CHANNEL (канал "X") - * - CONNECTION (type=3): одна линия - * - USER_PARAM (type=4): одна линия - * - * ВАЖНО: - * - prevLineNumber — это GLOBAL blockNumber предыдущего блока линии. - * - thisLineNumber — внутренний номер линии (для постов: 0,1,2...; для тех-линии: 1,2,3...) - * - lineCode — код линии: - * * 0 для канала "0" и для "простых" линий (connection/user_param/tech) - * * для каналов !=0: lineCode = blockNumber "заглавия" канала (CREATE_CHANNEL) - */ -public final class ChainState { - - public static final short TYPE_TECH = 0; // header/create_channel - public static final short TYPE_TEXT = 1; - public static final short TYPE_REACTION = 2; - public static final short TYPE_CONNECTION = 3; - public static final short TYPE_USER_PARAM = 4; - - private static final byte[] ZERO32 = new byte[32]; - private static final String ZERO64 = "0".repeat(64); - - // global chain - private int lastBlockNumber = -1; - private String lastBlockHashHex = ZERO64; - - // header (block#0) - private byte[] headerHash32 = null; - - private final Map hash32ByNumber = new HashMap<>(); - - // ---------- TECH line state ---------- - private static final class TechLineState { - int lastGlobalNumber = -1; // последний TECH-блок (HEADER или CREATE_CHANNEL) - String lastHashHex = ""; - int lastThisLineNumber = 0; // 0 у HEADER (логически), дальше 1,2,3... - - void reset() { - lastGlobalNumber = -1; - lastHashHex = ""; - lastThisLineNumber = 0; - } - } - - private final TechLineState techLine = new TechLineState(); - - // ---------- CONNECTION/USER_PARAM line state ---------- - private static final class SimpleLineState { - int lastGlobalNumber = -1; - String lastHashHex = ""; - int lastThisLineNumber = 0; - - void reset() { - lastGlobalNumber = -1; - lastHashHex = ""; - lastThisLineNumber = 0; - } - } - - private final SimpleLineState connectionLine = new SimpleLineState(); - private final SimpleLineState userParamLine = new SimpleLineState(); - - // ---------- TEXT channels ---------- - public static final class ChannelLineState { - final int lineCode; // для каналов: = rootBlockNumber; для канала 0: 0 - final int rootBlockNumber; // 0 для канала 0, иначе blockNumber CREATE_CHANNEL - final String rootHashHex; - - int lastGlobalNumber; - String lastHashHex; - int lastThisLineNumber; // перед первым постом = -1, чтобы первый был 0 - - ChannelLineState(int lineCode, int rootBlockNumber, String rootHashHex) { - this.lineCode = lineCode; - this.rootBlockNumber = rootBlockNumber; - this.rootHashHex = rootHashHex; - this.lastGlobalNumber = rootBlockNumber; - this.lastHashHex = rootHashHex; - this.lastThisLineNumber = -1; - } - } - - // lineCode -> state (для канала 0 lineCode=0) - private final Map textChannels = new HashMap<>(); - - public ChainState() { - techLine.reset(); - connectionLine.reset(); - userParamLine.reset(); - } - - // -------------------- global getters -------------------- - - public int lastBlockNumber() { return lastBlockNumber; } - public String lastBlockHashHex() { return lastBlockHashHex; } - - public boolean hasHeader() { - return headerHash32 != null && headerHash32.length == 32 && lastBlockNumber >= 0; - } - - public int nextBlockNumber() { - return lastBlockNumber + 1; - } - - public byte[] prevHash32ForNext() { - if (lastBlockNumber < 0) return ZERO32; - return hexToBytes32(lastBlockHashHex); - } - - public byte[] headerHash32() { - return headerHash32 == null ? null : headerHash32.clone(); - } - - public byte[] getHash32(int blockNumber) { - byte[] h = hash32ByNumber.get(blockNumber); - return h == null ? null : h.clone(); - } - - // -------------------- line helpers -------------------- - - public static final class NextLine { - public final int lineCode; - public final int prevLineNumber; // GLOBAL blockNumber - public final byte[] prevLineHash32; // 32 bytes - public final int thisLineNumber; // внутр. номер линии - - public NextLine(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber) { - this.lineCode = lineCode; - this.prevLineNumber = prevLineNumber; - this.prevLineHash32 = (prevLineHash32 == null ? null : prevLineHash32.clone()); - this.thisLineNumber = thisLineNumber; - } - } - - /** Следующие line-поля для TECH/CONNECTION/USER_PARAM. lineCode=0. */ - public NextLine nextLineByType(short type) { - if (!hasHeader()) { - throw new IllegalStateException("Нельзя формировать line-поля до HEADER (нет headerHash32)"); - } - - int t = type & 0xFFFF; - - if (t == TYPE_TECH) { - if (techLine.lastGlobalNumber == -1) { - throw new IllegalStateException("TECH line is not initialized yet"); - } - return new NextLine( - 0, - techLine.lastGlobalNumber, - hexToBytes32(techLine.lastHashHex), - techLine.lastThisLineNumber + 1 - ); - } - - if (t == TYPE_CONNECTION) { - return nextSimpleLine(connectionLine); - } - if (t == TYPE_USER_PARAM) { - return nextSimpleLine(userParamLine); - } - - throw new IllegalArgumentException("Type " + t + " не поддерживает nextLineByType()"); - } - - private NextLine nextSimpleLine(SimpleLineState ls) { - if (ls.lastGlobalNumber == -1) { - // первый блок линии ссылается на HEADER (block#0) - return new NextLine(0, 0, headerHash32.clone(), 1); - } - if (ls.lastHashHex == null || ls.lastHashHex.isBlank()) { - throw new IllegalStateException("LineState.lastHashHex пуст, но lastGlobalNumber!=-1"); - } - return new NextLine(0, ls.lastGlobalNumber, hexToBytes32(ls.lastHashHex), ls.lastThisLineNumber + 1); - } - - /** - * Следующие line-поля для TEXT-канала по lineCode. - * Для канала 0: lineCode=0. - * Для других каналов: lineCode = rootBlockNumber (CREATE_CHANNEL blockNumber). - */ - public NextLine nextTextLineByCode(int lineCode) { - if (!hasHeader()) throw new IllegalStateException("No HEADER"); - ChannelLineState cs = textChannels.get(lineCode); - if (cs == null) throw new IllegalStateException("Unknown TEXT channel lineCode=" + lineCode); - - return new NextLine( - lineCode, - cs.lastGlobalNumber, - hexToBytes32(cs.lastHashHex), - cs.lastThisLineNumber + 1 - ); - } - - /** Старое имя — оставил для удобства: rootBlockNumber == lineCode для каналов. */ - public NextLine nextTextLineByRoot(int rootBlockNumber) { - return nextTextLineByCode(rootBlockNumber); - } - - /** - * Зарегистрировать новый канал TEXT: - * - lineCode = rootBlockNumber (blockNumber CREATE_CHANNEL) - * ИДЕМПОТЕНТНО: если уже зарегистрирован — ничего не делаем. - */ - public void registerTextChannelRoot(int rootBlockNumber, byte[] rootHash32) { - if (rootBlockNumber < 0) throw new IllegalArgumentException("rootBlockNumber must be >= 0"); - if (rootHash32 == null || rootHash32.length != 32) throw new IllegalArgumentException("rootHash32 invalid"); - - if (textChannels.containsKey(rootBlockNumber)) { - return; // уже есть — не трогаем, чтобы не сбросить lastThisLineNumber и т.д. - } - - int lineCode = rootBlockNumber; - textChannels.put(lineCode, new ChannelLineState(lineCode, rootBlockNumber, bytesToHex64(rootHash32))); - } - - /** root/lineCode канала "0" (по умолчанию) — это HEADER block#0, lineCode=0. */ - public int rootChannel0() { - return 0; - } - - // -------------------- apply -------------------- - - public void applyAppendedBlock(int blockNumber, byte[] hash32, boolean isHeader, short type, BodyRecord body) { - if (hash32 == null || hash32.length != 32) { - throw new IllegalArgumentException("hash32 must be 32 bytes"); - } - if (blockNumber != lastBlockNumber + 1) { - throw new IllegalStateException("blockNumber sequence broken: expected=" + (lastBlockNumber + 1) + " got=" + blockNumber); - } - - if (isHeader) { - if (blockNumber != 0) throw new IllegalStateException("HEADER must be blockNumber=0"); - headerHash32 = hash32.clone(); - } else { - if (blockNumber == 0) throw new IllegalStateException("Non-header block can't be blockNumber=0"); - if (headerHash32 == null) throw new IllegalStateException("Header must be sent before non-header blocks"); - } - - String hex64 = bytesToHex64(hash32); - - lastBlockNumber = blockNumber; - lastBlockHashHex = hex64; - - hash32ByNumber.put(blockNumber, hash32.clone()); - - // ---- init after HEADER ---- - if (isHeader) { - // TECH line root = HEADER - techLine.lastGlobalNumber = 0; - techLine.lastHashHex = hex64; - techLine.lastThisLineNumber = 0; - - // TEXT channel "0" root = HEADER, lineCode=0 - registerTextChannelRoot(0, hash32); - - return; - } - - int t = type & 0xFFFF; - - // ---- TECH (CREATE_CHANNEL) ---- - if (t == TYPE_TECH && body instanceof CreateChannelBody ccb) { - techLine.lastGlobalNumber = blockNumber; - techLine.lastHashHex = hex64; - techLine.lastThisLineNumber = ccb.thisLineNumber; - - // ВАЖНО: CREATE_CHANNEL — это root нового текстового канала: - // lineCode для этого канала = blockNumber CREATE_CHANNEL - registerTextChannelRoot(blockNumber, hash32); - - return; - } - - // ---- CONNECTION / USER_PARAM ---- - if (t == TYPE_CONNECTION && body instanceof BodyHasLine hlc) { - connectionLine.lastGlobalNumber = blockNumber; - connectionLine.lastHashHex = hex64; - connectionLine.lastThisLineNumber = hlc.lineSeq(); - return; - } - if (t == TYPE_USER_PARAM && body instanceof BodyHasLine hlu) { - userParamLine.lastGlobalNumber = blockNumber; - userParamLine.lastHashHex = hex64; - userParamLine.lastThisLineNumber = hlu.lineSeq(); - return; - } - - // ---- TEXT channels (POST/EDIT_POST) ---- - if (t == TYPE_TEXT && body instanceof TextBody tb) { - if (tb.isLineMessage()) { - int lineCode = tb.lineCode; - - ChannelLineState channel = textChannels.get(lineCode); - if (channel == null) { - throw new IllegalStateException( - "TEXT line message has unknown lineCode=" + lineCode + - " (канал не зарегистрирован; ждали CREATE_CHANNEL или HEADER)" - ); - } - - channel.lastGlobalNumber = blockNumber; - channel.lastHashHex = hex64; - channel.lastThisLineNumber = tb.thisLineNumber; - } - } - } - - // -------------------- utils -------------------- - - private static byte[] hexToBytes32(String hex) { - if (hex == null) throw new IllegalArgumentException("hex is null"); - String s = hex.trim(); - if (s.length() != 64) throw new IllegalArgumentException("hex must be 64 chars, got " + s.length()); - byte[] out = new byte[32]; - for (int i = 0; i < 32; i++) { - int hi = Character.digit(s.charAt(i * 2), 16); - int lo = Character.digit(s.charAt(i * 2 + 1), 16); - if (hi < 0 || lo < 0) throw new IllegalArgumentException("bad hex at pos " + (i * 2)); - out[i] = (byte) ((hi << 4) | lo); - } - return out; - } - - private static String bytesToHex64(byte[] b32) { - char[] out = new char[64]; - final char[] HEX = "0123456789abcdef".toCharArray(); - for (int i = 0; i < 32; i++) { - int v = b32[i] & 0xFF; - out[i * 2] = HEX[v >>> 4]; - out[i * 2 + 1] = HEX[v & 0x0F]; - } - return new String(out); - } -} -package test.it.blockchain; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -/** - * JsonMini — маленькие утилиты, чтобы не раздувать зависимости. - */ -final class JsonMini { - private static final ObjectMapper M = new ObjectMapper(); - private JsonMini() {} - - static String extractPayloadString(String json, String field) { - try { - JsonNode root = M.readTree(json); - JsonNode payload = root.get("payload"); - if (payload != null && payload.has(field)) { - JsonNode v = payload.get(field); - return (v == null || v.isNull()) ? null : v.asText(); - } - } catch (Exception ignore) {} - return null; - } -} -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.TestResult; -import test.it.utils.ws.WsSession; - -import java.time.Duration; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.fail; - -/** - * IT_01_AddUser - * Создаёт 3 пользователей: TestUser1/2/3 (200 OK или 409 USER_ALREADY_EXISTS). - * - * Обновление: - * - теперь AddUser может вернуть 409 не только USER_ALREADY_EXISTS, - * но и BLOCKCHAIN_ALREADY_EXISTS / BLOCKCHAIN_STATE_ALREADY_EXISTS. - * - дополнительно проверяем GetUser (status=200 всегда). - * - добавлен SearchUsers: поиск по префиксу (первые 3 символа). - */ -public class IT_01_AddUser { - - public static void main(String[] args) { - String summary = run(); - System.out.println(summary); - } - - public static String run() { - TestResult r = new TestResult("IT_01_AddUser"); - - Duration t = Duration.ofSeconds(5); - - try (WsSession ws = WsSession.open()) { - - r.ok("AddUser USER1: " + TestConfig.LOGIN()); - String resp1 = ws.call("AddUser#USER1", JsonBuilders.addUser(TestConfig.LOGIN()), t); - checkAddUser200or409(r, resp1); - checkGetUserMustExist(r, ws, TestConfig.LOGIN(), t); - - r.ok("AddUser USER2: " + TestConfig.LOGIN2()); - String resp2 = ws.call("AddUser#USER2", JsonBuilders.addUser(TestConfig.LOGIN2()), t); - checkAddUser200or409(r, resp2); - checkGetUserMustExist(r, ws, TestConfig.LOGIN2(), t); - - r.ok("AddUser USER3: " + TestConfig.LOGIN3()); - String resp3 = ws.call("AddUser#USER3", JsonBuilders.addUser(TestConfig.LOGIN3()), t); - checkAddUser200or409(r, resp3); - checkGetUserMustExist(r, ws, TestConfig.LOGIN3(), t); - - // Доп: проверяем case-insensitive поиск в GetUser - String mixed = mixCase(TestConfig.LOGIN()); - r.ok("GetUser case-insensitive: запрос=" + mixed + " (должен найти " + TestConfig.LOGIN() + ")"); - checkGetUserMustExist(r, ws, mixed, t); - - // Доп: проверяем "не существует" (но status=200) - String missing = "NoSuchUser_987654321"; - r.ok("GetUser missing: " + missing); - checkGetUserMustNotExist(r, ws, missing, t); - - // SearchUsers: один раз ищем по первым трём символам логина USER1 - String prefix3 = first3(TestConfig.LOGIN()); - String prefix3Mixed = mixCase(prefix3); - r.ok("SearchUsers: prefix(3)='" + prefix3Mixed + "' (должен вернуть список и содержать " + TestConfig.LOGIN() + ")"); - checkSearchUsersMustContain(r, ws, prefix3Mixed, TestConfig.LOGIN(), t); - - } catch (Throwable e) { - r.fail("IT_01_AddUser упал: " + e.getMessage()); - } - - return r.summaryLine(); - } - - private static void checkAddUser200or409(TestResult r, String resp) { - int st = JsonParsers.status(resp); - if (st == 200) { - r.ok("AddUser: status=200 (создан)"); - return; - } - if (st == 409) { - String code = JsonParsers.errorCode(resp); - - // раньше был только USER_ALREADY_EXISTS, теперь добавились ещё варианты - if ("USER_ALREADY_EXISTS".equals(code)) { - r.ok("AddUser: status=409 USER_ALREADY_EXISTS (уже был)"); - return; - } - if ("BLOCKCHAIN_ALREADY_EXISTS".equals(code)) { - r.ok("AddUser: status=409 BLOCKCHAIN_ALREADY_EXISTS (blockchainName уже занят)"); - return; - } - if ("BLOCKCHAIN_STATE_ALREADY_EXISTS".equals(code)) { - r.ok("AddUser: status=409 BLOCKCHAIN_STATE_ALREADY_EXISTS (blockchain_state уже есть)"); - return; - } - - r.fail("AddUser: status=409 но code=" + code + ", resp=" + resp); - fail("AddUser unexpected 409 code=" + code); - } - r.fail("AddUser: неожиданный status=" + st + ", resp=" + resp); - fail("AddUser unexpected status=" + st); - } - - private static void checkGetUserMustExist(TestResult r, WsSession ws, String loginQuery, Duration t) { - String resp = ws.call("GetUser#" + loginQuery, JsonBuilders.getUser(loginQuery), t); - - int st = JsonParsers.status(resp); - if (st != 200) { - r.fail("GetUser: ожидали status=200, получили " + st + ", resp=" + resp); - fail("GetUser unexpected status=" + st); - } - - Boolean exists = JsonParsers.exists(resp); - if (exists == null || !exists) { - r.fail("GetUser: ожидали exists=true, resp=" + resp); - fail("GetUser expected exists=true"); - } - - // Проверяем, что сервер возвращает данные - String login = JsonParsers.userLogin(resp); - String blockchainName = JsonParsers.userBlockchainName(resp); - String solanaKey = JsonParsers.userSolanaKey(resp); - String blockchainKey = JsonParsers.userBlockchainKey(resp); - String deviceKey = JsonParsers.userDeviceKey(resp); - - if (isBlank(login) || isBlank(blockchainName) || isBlank(solanaKey) || isBlank(blockchainKey) || isBlank(deviceKey)) { - r.fail("GetUser: exists=true, но поля пустые/неполные, resp=" + resp); - fail("GetUser returned incomplete user data"); - } - - // ВАЖНО: - // Поиск делается без учета регистра, но login/blockchainName должны вернуться как в БД. - // Для тех логинов, которые мы создаем в тесте, это ровно TestConfig.LOGIN*(). - // Поэтому если запрос был смешанный регистр — сравниваем не с loginQuery, а с "каноничным" логином из конфига. - String canonical = canonicalLogin(loginQuery); - if (canonical != null) { - if (!login.equals(canonical)) { - r.fail("GetUser: login должен вернуться как в БД. expected=" + canonical + ", got=" + login + ", resp=" + resp); - fail("GetUser wrong login case"); - } - - String expectedBch = TestConfig.getBlockchainName(canonical); - if (!blockchainName.equals(expectedBch)) { - r.fail("GetUser: blockchainName должен вернуться как в БД. expected=" + expectedBch + ", got=" + blockchainName + ", resp=" + resp); - fail("GetUser wrong blockchainName"); - } - - // ключи должны совпадать с теми, что AddUser использует при регистрации - String expSol = TestConfig.solanaPublicKeyB64(canonical); - String expBchKey = TestConfig.blockchainPublicKeyB64(canonical); - String expDev = TestConfig.devicePublicKeyB64(canonical); - - if (!solanaKey.equals(expSol)) { - r.fail("GetUser: solanaKey mismatch, resp=" + resp); - fail("GetUser solanaKey mismatch"); - } - if (!blockchainKey.equals(expBchKey)) { - r.fail("GetUser: blockchainKey mismatch, resp=" + resp); - fail("GetUser blockchainKey mismatch"); - } - if (!deviceKey.equals(expDev)) { - r.fail("GetUser: deviceKey mismatch, resp=" + resp); - fail("GetUser deviceKey mismatch"); - } - } - - r.ok("GetUser: exists=true, login=" + login + ", blockchainName=" + blockchainName); - } - - private static void checkGetUserMustNotExist(TestResult r, WsSession ws, String loginQuery, Duration t) { - String resp = ws.call("GetUser#" + loginQuery, JsonBuilders.getUser(loginQuery), t); - - int st = JsonParsers.status(resp); - if (st != 200) { - r.fail("GetUser(not exist): ожидали status=200, получили " + st + ", resp=" + resp); - fail("GetUser(not exist) unexpected status=" + st); - } - - Boolean exists = JsonParsers.exists(resp); - if (exists == null) { - r.fail("GetUser(not exist): payload.exists отсутствует, resp=" + resp); - fail("GetUser(not exist) missing exists"); - } - if (exists) { - r.fail("GetUser(not exist): ожидали exists=false, resp=" + resp); - fail("GetUser(not exist) expected exists=false"); - } - - r.ok("GetUser: exists=false (ok)"); - } - - private static void checkSearchUsersMustContain(TestResult r, WsSession ws, String prefix, String expectedLogin, Duration t) { - String resp = ws.call("SearchUsers#" + prefix, JsonBuilders.searchUsers(prefix), t); - - int st = JsonParsers.status(resp); - if (st != 200) { - r.fail("SearchUsers: ожидали status=200, получили " + st + ", resp=" + resp); - fail("SearchUsers unexpected status=" + st); - } - - List logins = JsonParsers.searchLogins(resp); - if (logins == null || logins.isEmpty()) { - r.fail("SearchUsers: ожидали непустой список, resp=" + resp); - fail("SearchUsers expected non-empty list"); - } - - // ВАЖНО: ожидаемый логин должен быть в ответе в регистре БД (каноничный expectedLogin) - boolean found = false; - for (String s : logins) { - if (expectedLogin.equals(s)) { - found = true; - break; - } - } - if (!found) { - r.fail("SearchUsers: ожидаемый логин не найден. expected=" + expectedLogin + ", got=" + logins + ", resp=" + resp); - fail("SearchUsers expected login not found"); - } - - r.ok("SearchUsers: ok, prefix=" + prefix + ", results=" + logins.size() + ", contains=" + expectedLogin); - } - - private static String canonicalLogin(String anyCaseLogin) { - if (anyCaseLogin == null) return null; - String x = anyCaseLogin.trim(); - if (x.isEmpty()) return null; - - // Привязка только к нашим тестовым логинам, чтобы не гадать. - if (x.equalsIgnoreCase(TestConfig.LOGIN())) return TestConfig.LOGIN(); - if (x.equalsIgnoreCase(TestConfig.LOGIN2())) return TestConfig.LOGIN2(); - if (x.equalsIgnoreCase(TestConfig.LOGIN3())) return TestConfig.LOGIN3(); - - return null; - } - - private static String mixCase(String s) { - if (s == null) return null; - String x = s.trim(); - if (x.length() < 2) return x; - // простой "микс" без рандома, чтобы тест был детерминированный - return Character.toUpperCase(x.charAt(0)) + x.substring(1).toLowerCase(); - } - - private static String first3(String s) { - if (s == null) return ""; - String x = s.trim(); - if (x.length() <= 3) return x; - return x.substring(0, 3); - } - - private static boolean isBlank(String s) { - return s == null || s.trim().isEmpty(); - } -} -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 java.time.Duration; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * IT_02_Sessions (v2) - * - * Цель: - * - проверить создание/листинг/вход-в-сессию(2 шага)/close - * - и после завершения оставить в БД 3 активных сессии (S1,S2,S3) - * - * Протокол v2: - * - создание сессии: AuthChallenge -> CreateAuthSession (deviceKey подпись, + sessionPubKey) - * - вход в сессию: SessionChallenge(sessionId) -> nonce, затем SessionLogin(sessionId,time,signature(sessionKey)) - * - ListSessions и CloseActiveSession доступны только в AUTH_STATUS_USER (после SessionLogin) - */ -public class IT_02_Sessions { - - private static final String LOGIN = TestConfig.LOGIN(); - - public static void main(String[] args) { - TestLog.info("Standalone: этот тест требует заранее созданных пользователей -> сначала запускаю IT_01_AddUser"); - System.out.println(IT_01_AddUser.run()); - String summary = run(); - System.out.println(summary); - } - - public static String run() { - TestResult r = new TestResult("IT_02_Sessions(v2)"); - - Duration t = Duration.ofSeconds(5); - - Session s1, s2, s3; - - try { - // 1) Создаём 3 сессии (каждая — отдельным соединением) - s1 = createSession(LOGIN, t, r, "S1"); - s2 = createSession(LOGIN, t, r, "S2"); - s3 = createSession(LOGIN, t, r, "S3"); - - // 2) Входим в S1 (2 шага) и делаем ListSessions (AUTH_STATUS_USER) — должны быть S1,S2,S3 - try (WsSession ws = WsSession.open()) { - sessionLogin2Steps(ws, s1, t, "Login(S1)", r); - - String listResp = ws.call("ListSessions(AUTH_STATUS_USER)", JsonBuilders.listSessions(0L, ""), t); - assertEquals(200, JsonParsers.status(listResp), "ListSessions(AUTH_STATUS_USER) must be 200"); - - List ids = JsonParsers.sessionIds(listResp); - r.ok("ListSessions(AUTH_STATUS_USER): " + ids); - - assertTrue(ids.contains(s1.sessionId), "Must contain S1"); - assertTrue(ids.contains(s2.sessionId), "Must contain S2"); - assertTrue(ids.contains(s3.sessionId), "Must contain S3"); - r.ok("Проверка OK: список содержит S1,S2,S3"); - } - - // 3) Проверяем CloseActiveSession так, чтобы итогом всё равно осталось 3 сессии: - // создаём TEMP, логинимся в S1, закрываем TEMP, убеждаемся что S1,S2,S3 остались. - Session temp = createSession(LOGIN, t, r, "TEMP"); - - try (WsSession ws = WsSession.open()) { - sessionLogin2Steps(ws, s1, t, "Login(S1) for close", r); - - String closeResp = ws.call("CloseActiveSession(TEMP)", JsonBuilders.closeActiveSession(temp.sessionId, 0L, ""), t); - assertEquals(200, JsonParsers.status(closeResp), "CloseActiveSession(TEMP) must be 200"); - r.ok("CloseActiveSession(TEMP): OK"); - } - - // 4) Финальная проверка: снова логинимся в S1 и ListSessions => S1,S2,S3 должны остаться, TEMP нет - try (WsSession ws = WsSession.open()) { - sessionLogin2Steps(ws, s1, t, "Final Login(S1)", r); - - String listResp = ws.call("ListSessions(final)", JsonBuilders.listSessions(0L, ""), t); - assertEquals(200, JsonParsers.status(listResp)); - - List ids = JsonParsers.sessionIds(listResp); - r.ok("Final ListSessions: " + ids); - - assertTrue(ids.contains(s1.sessionId)); - assertTrue(ids.contains(s2.sessionId)); - assertTrue(ids.contains(s3.sessionId)); - assertFalse(ids.contains(temp.sessionId)); - r.ok("ИТОГ OK: после теста в БД остались 3 активные сессии (S1,S2,S3)"); - } - - } catch (Throwable e) { - r.fail("IT_02_Sessions(v2) упал: " + e.getMessage()); - } - - return r.summaryLine(); - } - - private static Session createSession(String login, Duration t, TestResult r, String label) { - try (WsSession ws = WsSession.open()) { - - // шаг 1: AuthChallenge - String nonceResp = ws.call("AuthChallenge(" + label + ")", JsonBuilders.authChallenge(login), t); - assertEquals(200, JsonParsers.status(nonceResp), "AuthChallenge(" + label + ") must be 200"); - String authNonce = JsonParsers.authNonce(nonceResp); - assertNotNull(authNonce, "authNonce must not be null for " + label); - - // для тестов: sessionKey = deviceKey (в реале будет отдельный keypair) - String sessionPubKeyB64 = TestConfig.devicePublicKeyB64(login); - - // storagePwd на клиенте (сохраняем, чтобы потом проверить, что сервер вернул именно его) - String storagePwd = TestConfig.fakeStoragePwd(); - - // шаг 2: CreateAuthSession (device подпись + sessionPubKey) - String createResp = ws.call( - "CreateAuthSession(" + label + ")", - JsonBuilders.createAuthSessionV2(login, authNonce, storagePwd, sessionPubKeyB64), - t - ); - assertEquals(200, JsonParsers.status(createResp), "CreateAuthSession(" + label + ") must be 200"); - - String sid = JsonParsers.sessionId(createResp); - assertNotNull(sid, "sessionId must not be null"); - - r.ok("Создана сессия " + label + ": sessionId=" + sid); - - // для тестов используем devicePriv как sessionPriv - byte[] sessionPrivKey = TestConfig.getDevicePrivatKey(login); - - return new Session(sid, sessionPrivKey, storagePwd); - } - } - - private static void sessionLogin2Steps(WsSession ws, Session s, Duration t, String label, TestResult r) { - // шаг 1: SessionChallenge(sessionId) - String chResp = ws.call("SessionChallenge " + label, JsonBuilders.sessionChallenge(s.sessionId), t); - assertEquals(200, JsonParsers.status(chResp), "SessionChallenge must be 200"); - String nonce = JsonParsers.sessionNonce(chResp); - assertNotNull(nonce, "SessionChallenge nonce must not be null"); - - // шаг 2: SessionLogin(sessionId, timeMs, signature(sessionKey, SESSION_LOGIN:...)) - String loginResp = ws.call("SessionLogin " + label, JsonBuilders.sessionLogin(s.sessionId, nonce, s.sessionPrivKey), t); - assertEquals(200, JsonParsers.status(loginResp), "SessionLogin must be 200"); - - String storagePwd = JsonParsers.storagePwd(loginResp); - assertNotNull(storagePwd, "storagePwd must not be null after SessionLogin"); - assertEquals(s.storagePwd, storagePwd, "storagePwd must match what client provided on CreateAuthSession"); - - r.ok(label + ": SessionLogin OK, storagePwd verified"); - } - - private record Session(String sessionId, byte[] sessionPrivKey, String storagePwd) {} -} -package test.it.cases; - -import blockchain.MsgSubType; -import blockchain.body.ConnectionBody; -import blockchain.body.CreateChannelBody; -import blockchain.body.HeaderBody; -import blockchain.body.TextBody; -import test.it.blockchain.AddBlockSender; -import test.it.blockchain.ChainState; -import test.it.utils.TestConfig; -import test.it.utils.log.TestLog; -import test.it.utils.log.TestResult; -import test.it.utils.ws.WsSession; - -import java.time.Duration; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * IT_03_AddBlock_NoAuth — сценарий блоков (новый формат + каналы + связи). - * - * CONNECTION (type=3): - * - всегда имеет hasLine (lineCode+prevLineNumber+prevLineHash32+thisLineNumber) - * - всегда имеет target: - * toBlockchainName + toBlockGlobalNumber + toBlockHash32 - * - * Правило target для связей/подписок: - * - FRIEND/CONTACT -> target = HEADER цели (blockNumber=0) - * - FOLLOW пользователя -> target = HEADER цели (blockNumber=0) - * - FOLLOW канала -> target = ROOT канала: - * канал "0" -> HEADER (0) - * канал "X" -> CREATE_CHANNEL (blockNumber create_channel) - */ -public class IT_03_AddBlock_NoAuth { - - public static void main(String[] args) { - TestLog.info("Standalone: этот тест требует заранее созданных пользователей -> запускаю IT_01_AddUser"); - System.out.println(IT_01_AddUser.run()); - String summary = run(); - System.out.println(summary); - } - - public static String run() { - TestResult r = new TestResult("IT_03_AddBlock_NoAuth"); - - String u1 = TestConfig.LOGIN(); - String u2 = TestConfig.LOGIN2(); - String u3 = TestConfig.LOGIN3(); - - String bch1 = TestConfig.getBlockchainName(u1); - String bch2 = TestConfig.getBlockchainName(u2); - String bch3 = TestConfig.getBlockchainName(u3); - - Duration t = Duration.ofSeconds(1); - - try (WsSession ws = WsSession.open()) { - - if (TestConfig.DEBUG()) { - TestLog.titleBlock( - "IT_03:\n" + - " USER1=" + u1 + " bch=" + bch1 + "\n" + - " USER2=" + u2 + " bch=" + bch2 + "\n" + - " USER3=" + u3 + " bch=" + bch3 + "\n" + - "\nСценарий: каналы + кросс-чейн reply + connections (follow/friend/contact/uncontact)." - ); - } - - // ========================= - // USER1 - // ========================= - ChainState st1 = new ChainState(); - AddBlockSender sender1 = new AddBlockSender(ws, st1, u1, bch1, TestConfig.getBlockchainPrivatKey(u1)); - - sender1.send(new HeaderBody(u1), t); - assertTrue(st1.hasHeader()); - - int u1HeaderBlock = 0; - byte[] u1HeaderHash = st1.getHash32(u1HeaderBlock); - assertNotNull(u1HeaderHash); - - // канал "0" root = HEADER (0) - int root0 = st1.rootChannel0(); - - // POST в канал "0" - { - var ln = st1.nextTextLineByRoot(root0); - sender1.send(new TextBody( - MsgSubType.TEXT_POST, - root0, - ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - "U1: story/post in channel 0", - null, null, null - ), t); - } - - int post0Block = st1.lastBlockNumber(); - byte[] post0Hash = st1.getHash32(post0Block); - assertNotNull(post0Hash); - - // CREATE_CHANNEL "News" (TECH line) - int newsRootBlock; - byte[] newsRootHash; - { - var ln = st1.nextLineByType(ChainState.TYPE_TECH); - sender1.send(new CreateChannelBody( - 0, // lineCode TECH - ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - "News" - ), t); - - newsRootBlock = st1.lastBlockNumber(); // root канала = blockNumber этого CREATE_CHANNEL - newsRootHash = st1.getHash32(newsRootBlock); - assertNotNull(newsRootHash); - - st1.registerTextChannelRoot(newsRootBlock, newsRootHash); - } - - // POST #0 в канал "News" - int newsPost0Block; - byte[] newsPost0Hash; - { - var ln = st1.nextTextLineByRoot(newsRootBlock); - sender1.send(new TextBody( - MsgSubType.TEXT_POST, - newsRootBlock, - ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - "U1: News post #0", - null, null, null - ), t); - - newsPost0Block = st1.lastBlockNumber(); - newsPost0Hash = st1.getHash32(newsPost0Block); - assertNotNull(newsPost0Hash); - } - - // POST #1 в канал "News" - { - var ln = st1.nextTextLineByRoot(newsRootBlock); - sender1.send(new TextBody( - MsgSubType.TEXT_POST, - newsRootBlock, - ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - "U1: News post #1", - null, null, null - ), t); - } - - // EDIT_POST (в линии канала) -> target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName) - { - var ln = st1.nextTextLineByRoot(newsRootBlock); - sender1.send(new TextBody( - MsgSubType.TEXT_EDIT_POST, - newsRootBlock, - ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, - "U1: News post #0 (EDIT)", - null, - newsPost0Block, - newsPost0Hash - ), t); - } - - // ========================= - // USER2 - // ========================= - ChainState st2 = new ChainState(); - AddBlockSender sender2 = new AddBlockSender(ws, st2, u2, bch2, TestConfig.getBlockchainPrivatKey(u2)); - - sender2.send(new HeaderBody(u2), t); - assertTrue(st2.hasHeader()); - - int u2HeaderBlock = 0; - byte[] u2HeaderHash = st2.getHash32(u2HeaderBlock); - assertNotNull(u2HeaderHash); - - // ========================= - // СВЯЗИ (CONNECTION) - // ========================= - - // 1) U1 подписался на U2 (FOLLOW на пользователя -> target=HEADER U2) - sendConnection(sender1, st1, MsgSubType.CONNECTION_FOLLOW, - bch2, u2HeaderBlock, u2HeaderHash, - "U1 follows U2 (target=U2 HEADER)", t); - - // 2) U2 подписался на канал U1 "News" (FOLLOW на канал -> target=root CREATE_CHANNEL U1) - sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW, - bch1, newsRootBlock, newsRootHash, - "U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t); - - // 3) FRIEND взаимно (на HEADER) - sendConnection(sender1, st1, MsgSubType.CONNECTION_FRIEND, - bch2, u2HeaderBlock, u2HeaderHash, - "U1 -> U2: FRIEND", t); - - sendConnection(sender2, st2, MsgSubType.CONNECTION_FRIEND, - bch1, u1HeaderBlock, u1HeaderHash, - "U2 -> U1: FRIEND", t); - - // 4) CONTACT несколько - sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT, - bch2, u2HeaderBlock, u2HeaderHash, - "U1 -> U2: CONTACT", t); - - sendConnection(sender2, st2, MsgSubType.CONNECTION_CONTACT, - bch1, u1HeaderBlock, u1HeaderHash, - "U2 -> U1: CONTACT", t); - - // ========================= - // USER2 REPLY (ответ в чужой канал) - // ========================= - { - sender2.send(TextBody.newReply( - bch1, - newsPost0Block, - newsPost0Hash, - "U2: reply to U1 News post #0 (cross-chain)" - ), t); - } - - // ========================= - // USER3 + доп. контакт - // ========================= - ChainState st3 = new ChainState(); - AddBlockSender sender3 = new AddBlockSender(ws, st3, u3, bch3, TestConfig.getBlockchainPrivatKey(u3)); - - sender3.send(new HeaderBody(u3), t); - assertTrue(st3.hasHeader()); - - int u3HeaderBlock = 0; - byte[] u3HeaderHash = st3.getHash32(u3HeaderBlock); - assertNotNull(u3HeaderHash); - - // U1 -> U3: CONTACT - sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT, - bch3, u3HeaderBlock, u3HeaderHash, - "U1 -> U3: CONTACT", t); - - // 5) U1 убирает U2 из контактов (UNCONTACT) - sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT, - bch2, u2HeaderBlock, u2HeaderHash, - "U1 -> U2: UNCONTACT", t); - - r.ok("IT_03 сценарий блоков + connections выполнен"); - - } catch (Throwable e) { - r.fail("IT_03 упал: " + e.getMessage()); - } - - return r.summaryLine(); - } - - /** - * Отправка 1 блока CONNECTION. - * - * ВАЖНО: ConnectionBody НЕ содержит note в байтах. - * Если нужно “описание” — логируем отдельно. - */ - private static void sendConnection(AddBlockSender sender, - ChainState st, - short subType, - String toBlockchainName, - int toBlockNumber, - byte[] toBlockHash32, - String logNote, - Duration timeout) { - - if (TestConfig.DEBUG()) { - TestLog.info("CONNECTION: subType=" + (subType & 0xFFFF) - + " to=" + toBlockchainName - + " targetBlock=" + toBlockNumber - + " note=" + logNote); - } - - var ln = st.nextLineByType(ChainState.TYPE_CONNECTION); - - // КОНСТРУКТОР ИЗ ТВОЕГО КОДА: - // ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber, - // short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) - sender.send(new ConnectionBody( - 0, // lineCode для connection линии - ln.prevLineNumber, - ln.prevLineHash32, - ln.thisLineNumber, - subType, - toBlockchainName, - toBlockNumber, - toBlockHash32 - ), timeout); - } -} -package test.it.cases; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import test.it.utils.*; -import test.it.utils.TestConfig; -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.config.ShineSignatureConstants; -import utils.crypto.Ed25519Util; - -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.Base64; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * IT_04_UserParams_NoAuth - * - * ВАЖНО: - * - пользователей НЕ создаём (их создаёт IT_01) - */ -public class IT_04_UserParams_NoAuth { - - private static final ObjectMapper M = new ObjectMapper(); - - public static void main(String[] args) { - TestLog.info("Standalone: этот тест требует заранее созданных пользователей -> сначала запускаю IT_01_AddUser"); - System.out.println(IT_01_AddUser.run()); - String summary = run(); - System.out.println(summary); - } - - public static String run() { - TestResult r = new TestResult("IT_04_UserParams_NoAuth"); - - Duration timeout = Duration.ofSeconds(5); - - final String login = TestConfig.LOGIN(); - final String deviceKeyB64 = TestConfig.devicePublicKeyB64(login); - final byte[] devicePrivKey = TestConfig.getDevicePrivatKey(login); - - try { - // 1) сохранить param1 - final String p1 = "profile:name"; - final String v1 = "Anna"; - final long t1 = System.currentTimeMillis(); - upsertUserParam_OK(r, login, p1, t1, v1, deviceKeyB64, devicePrivKey, timeout); - - // 2) получить param1 и проверить - NetParam got1 = getUserParam_200(r, login, p1, timeout); - assertEquals(login, got1.login); - assertEquals(p1, got1.param); - assertEquals(t1, got1.timeMs); - assertEquals(v1, got1.value); - assertEquals(deviceKeyB64, got1.deviceKeyB64); - assertNotNull(got1.signatureB64); - assertFalse(got1.signatureB64.isBlank()); - r.ok("GetUserParam(param1) OK"); - - // 3) сохранить param2 - final String p2 = "profile:city"; - final String v2 = "Amsterdam"; - final long t2 = t1 + 10; - upsertUserParam_OK(r, login, p2, t2, v2, deviceKeyB64, devicePrivKey, timeout); - - // 4) обновить param1 - final String v1b = "Anna Updated"; - final long t1b = t2 + 10; - upsertUserParam_OK(r, login, p1, t1b, v1b, deviceKeyB64, devicePrivKey, timeout); - - NetParam got1b = getUserParam_200(r, login, p1, timeout); - assertEquals(t1b, got1b.timeMs); - assertEquals(v1b, got1b.value); - r.ok("GetUserParam(updated param1) OK"); - - // 5) list всех параметров - NetParamList list = listUserParams_200(r, login, timeout); - - NetParam lp1 = list.find(p1); - NetParam lp2 = list.find(p2); - - assertNotNull(lp1, "ListUserParams должен содержать param1=" + p1); - assertNotNull(lp2, "ListUserParams должен содержать param2=" + p2); - - assertEquals(t1b, lp1.timeMs); - assertEquals(v1b, lp1.value); - - assertEquals(t2, lp2.timeMs); - assertEquals(v2, lp2.value); - - assertEquals(deviceKeyB64, lp1.deviceKeyB64); - assertEquals(deviceKeyB64, lp2.deviceKeyB64); - assertNotNull(lp1.signatureB64); - assertNotNull(lp2.signatureB64); - - r.ok("ListUserParams OK"); - - } catch (Throwable e) { - r.fail("IT_04 упал: " + e.getMessage()); - } - - return r.summaryLine(); - } - - // ================================================================================= - // WS helpers: Upsert/Get/List - // ================================================================================= - - private static void upsertUserParam_OK(TestResult r, String login, String param, long timeMs, String value, String deviceKeyB64, byte[] devicePrivKey, Duration timeout) { - String signatureB64 = signUserParam(devicePrivKey, login, param, timeMs, value); - - String reqJson = """ - { - "op": "UpsertUserParam", - "requestId": "%s", - "payload": { - "login": "%s", - "param": "%s", - "time_ms": %d, - "value": "%s", - "device_key": "%s", - "signature": "%s" - } - } - """.formatted(TestIds.next("upsert"), login, param, timeMs, jsonEscape(value), deviceKeyB64, signatureB64); - - try (WsSession ws = WsSession.open()) { - String resp = ws.call("UpsertUserParam(" + param + ")", reqJson, timeout); - assertEquals(200, JsonParsers.status(resp), "UpsertUserParam expected 200, resp=" + resp); - r.ok("UpsertUserParam(" + param + "): OK"); - } - } - - private static NetParam getUserParam_200(TestResult r, String login, String param, Duration timeout) { - String reqJson = """ - { - "op": "GetUserParam", - "requestId": "%s", - "payload": { - "login": "%s", - "param": "%s" - } - } - """.formatted(TestIds.next("getparam"), login, param); - - try (WsSession ws = WsSession.open()) { - String resp = ws.call("GetUserParam(" + param + ")", reqJson, timeout); - assertEquals(200, JsonParsers.status(resp), "GetUserParam expected 200, resp=" + resp); - r.ok("GetUserParam(" + param + "): OK"); - return parseParamFromResponsePayload(resp); - } - } - - private static NetParamList listUserParams_200(TestResult r, String login, Duration timeout) { - String reqJson = """ - { - "op": "ListUserParams", - "requestId": "%s", - "payload": { "login": "%s" } - } - """.formatted(TestIds.next("listparams"), login); - - try (WsSession ws = WsSession.open()) { - String resp = ws.call("ListUserParams", reqJson, timeout); - assertEquals(200, JsonParsers.status(resp), "ListUserParams expected 200, resp=" + resp); - r.ok("ListUserParams: OK"); - return parseParamListFromResponsePayload(resp); - } - } - - // ================================================================================= - // Parsing helpers - // ================================================================================= - - private static NetParam parseParamFromResponsePayload(String respJson) { - try { - JsonNode root = M.readTree(respJson); - JsonNode payload = root.get("payload"); - assertNotNull(payload, "payload is null: " + respJson); - - NetParam p = new NetParam(); - p.login = text(payload, "login"); - p.param = text(payload, "param"); - p.timeMs = longVal(payload, "time_ms"); - p.value = text(payload, "value"); - p.deviceKeyB64 = text(payload, "device_key"); - p.signatureB64 = text(payload, "signature"); - return p; - } catch (Exception e) { - throw new RuntimeException("Failed to parse GetUserParam response: " + respJson, e); - } - } - - private static NetParamList parseParamListFromResponsePayload(String respJson) { - try { - JsonNode root = M.readTree(respJson); - JsonNode payload = root.get("payload"); - assertNotNull(payload, "payload is null: " + respJson); - - NetParamList out = new NetParamList(); - out.login = text(payload, "login"); - - JsonNode arr = payload.get("params"); - assertNotNull(arr, "payload.params is null: " + respJson); - assertTrue(arr.isArray(), "payload.params must be array: " + respJson); - - for (JsonNode it : arr) { - NetParam p = new NetParam(); - p.login = text(it, "login"); - p.param = text(it, "param"); - p.timeMs = longVal(it, "time_ms"); - p.value = text(it, "value"); - p.deviceKeyB64 = text(it, "device_key"); - p.signatureB64 = text(it, "signature"); - out.items = out.itemsAppend(p); - } - return out; - } catch (Exception e) { - throw new RuntimeException("Failed to parse ListUserParams response: " + respJson, e); - } - } - - private static String text(JsonNode obj, String field) { - JsonNode v = obj.get(field); - return (v == null || v.isNull()) ? null : v.asText(); - } - - private static long longVal(JsonNode obj, String field) { - JsonNode v = obj.get(field); - if (v == null || v.isNull()) return 0; - return v.asLong(); - } - - // ================================================================================= - // Signature + JSON helpers - // ================================================================================= - - private static String signUserParam(byte[] devicePrivKey, String login, String param, long timeMs, String value) { - String signText = ShineSignatureConstants.USER_PARAMETER_PREFIX + login + param + timeMs + value; - byte[] signBytes = signText.getBytes(StandardCharsets.UTF_8); - byte[] sig64 = Ed25519Util.sign(signBytes, devicePrivKey); - return Base64.getEncoder().encodeToString(sig64); - } - - private static String jsonEscape(String s) { - if (s == null) return ""; - return s.replace("\\", "\\\\").replace("\"", "\\\""); - } - - // ================================================================================= - // DTOs - // ================================================================================= - - private static final class NetParam { - String login; - String param; - long timeMs; - String value; - String deviceKeyB64; - String signatureB64; - } - - private static final class NetParamList { - String login; - NetParam[] items = new NetParam[0]; - - NetParam[] itemsAppend(NetParam p) { - NetParam[] n = new NetParam[items.length + 1]; - System.arraycopy(items, 0, n, 0, items.length); - n[items.length] = p; - items = n; - return items; - } - - NetParam find(String param) { - for (NetParam p : items) { - if (p != null && param.equals(p.param)) return p; - } - return null; - } - } -} -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.TestResult; -import test.it.utils.ws.WsSession; - -import java.time.Duration; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.fail; - -/** - * IT_05_UserConnections - * - * Делает пару запросов GetFriendsLists (без проверок существования юзеров — это уже в IT_01). - * - * Ожидаемый формат ответа: - * { - * "op":"GetFriendsLists", - * "requestId":"...", - * "status":200, - * "payload":{ - * "login":"TestUser1", // канонический регистр из БД - * "out_friends":[...], // кому login поставил FRIEND - * "in_friends":[...] // кто поставил FRIEND login - * } - * } - * - * ВАЖНО: - * - login в запросе может быть в любом регистре, - * - но в ответе payload.login должен быть канонический (как в БД). - */ -public class IT_05_UserConnections { - - public static void main(String[] args) { - String summary = run(); - System.out.println(summary); - } - - public static String run() { - TestResult r = new TestResult("IT_05_UserConnections"); - Duration t = Duration.ofSeconds(5); - - final String u1 = TestConfig.LOGIN(); - final String u2 = TestConfig.LOGIN2(); - - try (WsSession ws = WsSession.open()) { - - // 1) Запрос списков связей для u1 (канонический регистр) - r.ok("GetFriendsLists USER1: " + u1); - String resp1 = ws.call("GetFriendsLists#U1", JsonBuilders.getFriendsLists(u1), t); - check200(r, resp1); - checkCanonicalLogin(r, resp1, u1); - checkTwoListsPresent(r, resp1); - - // 2) Запрос списков связей для u1 (смешанный регистр) - String u1mixed = mixCase(u1); - r.ok("GetFriendsLists USER1 mixed-case request: " + u1mixed + " (expect login=" + u1 + ")"); - String resp2 = ws.call("GetFriendsLists#U1_MIX", JsonBuilders.getFriendsLists(u1mixed), t); - check200(r, resp2); - checkCanonicalLogin(r, resp2, u1); - checkTwoListsPresent(r, resp2); - - // 3) Ещё один запрос — для u2 (просто чтобы "пару запросов") - r.ok("GetFriendsLists USER2: " + u2); - String resp3 = ws.call("GetFriendsLists#U2", JsonBuilders.getFriendsLists(u2), t); - check200(r, resp3); - checkCanonicalLogin(r, resp3, u2); - checkTwoListsPresent(r, resp3); - - // лог для наглядности (могут быть пустые, это ок) - List out1 = JsonParsers.friendsOut(resp1); - List in1 = JsonParsers.friendsIn(resp1); - - r.ok("Friends lists USER1: out=" + out1.size() + ", in=" + in1.size()); - - } catch (Throwable e) { - r.fail("IT_05_UserConnections упал: " + e.getMessage()); - } - - return r.summaryLine(); - } - - // ================= checks ================= - - private static void check200(TestResult r, String resp) { - int st = JsonParsers.status(resp); - if (st != 200) { - r.fail("ожидали status=200, получили " + st + ", resp=" + resp); - fail("unexpected status=" + st); - } - } - - private static void checkCanonicalLogin(TestResult r, String resp, String expectedCanonicalLogin) { - String got = JsonParsers.friendsLogin(resp); - if (got == null) { - r.fail("GetFriendsLists: payload.login отсутствует, resp=" + resp); - fail("GetFriendsLists missing payload.login"); - } - if (!expectedCanonicalLogin.equals(got)) { - r.fail("GetFriendsLists: login должен вернуться канонический. expected=" + expectedCanonicalLogin + ", got=" + got + ", resp=" + resp); - fail("GetFriendsLists wrong login case"); - } - } - - private static void checkTwoListsPresent(TestResult r, String resp) { - // В JsonParsers.getPayloadStringArray сейчас возвращает пустой список, даже если поле отсутствует/не массив. - // Поэтому дополнительно проверяем, что парсер вернул НЕ null (он и не должен возвращать null). - List out = JsonParsers.friendsOut(resp); - List in = JsonParsers.friendsIn(resp); - - if (out == null || in == null) { - r.fail("GetFriendsLists: out_friends/in_friends не должны быть null, resp=" + resp); - fail("GetFriendsLists lists are null"); - } - - // Просто отмечаем, что поля читаются, даже если пустые. - r.ok("GetFriendsLists lists present: out=" + out.size() + ", in=" + in.size()); - } - - private static String mixCase(String s) { - if (s == null) return null; - String x = s.trim(); - if (x.length() < 2) return x; - return Character.toUpperCase(x.charAt(0)) + x.substring(1).toLowerCase(); - } -} -package test.it; - -import test.it.runner.IT_RunAllMain; - -import java.util.Objects; - -public class IT_DeployRestartAndRunRemoteMain { - - // ====== НАСТРОЙКИ (можно переопределять systemProperty) ====== - private static final String REMOTE_HOST = System.getProperty("it.remoteHost", "194.87.0.247"); - private static final String REMOTE_USER = System.getProperty("it.remoteUser", "user"); - - private static final String REMOTE_DIR = System.getProperty("it.remoteDir", "/home/user/docker/shine-server"); - private static final String REMOTE_JAR = REMOTE_DIR + "/shine-server.jar"; - private static final String REMOTE_DATA = System.getProperty("it.remoteDataDir", REMOTE_DIR + "/data"); - - private static final String SERVICE_NAME = System.getProperty("it.service", "shine-server"); - - private static final String LOCAL_JAR = System.getProperty("it.localJar", "build/libs/shine-server.jar"); - - // URI для IT-тестов (переключаем на сервер) - private static final String WS_URI_SERVER = System.getProperty("it.wsUri", "wss://shineup.me/ws"); - - public static void main(String[] args) { - - // 0) Build shadowJar локально -// shStrict("./gradlew -q shadowJar"); - - // 1) stop service на сервере - sshStrict("sudo systemctl stop " + SERVICE_NAME + " || true"); - - // 2) upload jar -> .new - scpStrict(LOCAL_JAR, REMOTE_JAR + ".new"); - - // 3) заменить jar атомарно - sshStrict("mv -f " + q(REMOTE_JAR + ".new") + " " + q(REMOTE_JAR)); - - // 4) удалить data/* - // (на всякий случай: если папки нет — создать) - sshStrict("mkdir -p " + q(REMOTE_DATA) + " && rm -rf " + q(REMOTE_DATA) + "/*"); - - // 5) start service - sshStrict("sudo systemctl start " + SERVICE_NAME); - - // 6) дождаться поднятия (простая проверка: порт слушается) - waitRemotePort7070(); - - // 7) переключаем IT на серверный WS URI (без правок исходников) - System.setProperty("it.wsUri", WS_URI_SERVER); - - // 8) прогон тестов - int failed = IT_RunAllMain.runAll(); - System.exit(failed); - } - - private static void waitRemotePort7070() { - for (int i = 0; i < 50; i++) { - int code = ssh("ss -ltnp | grep -q ':7070'"); // 0 если найдено - if (code == 0) return; - sleepMs(200); - } - throw new RuntimeException("Remote port 7070 did not start in time on " + REMOTE_HOST); - } - - // ---------- helpers ---------- - private static void shStrict(String cmd) { - int code = sh(cmd); - if (code != 0) throw new RuntimeException("Command failed (" + code + "): " + cmd); - } - - private static void sshStrict(String remoteCmd) { - int code = ssh(remoteCmd); - if (code != 0) throw new RuntimeException("SSH command failed (" + code + "): " + remoteCmd); - } - - private static int ssh(String remoteCmd) { - String cmd = "ssh " + REMOTE_USER + "@" + REMOTE_HOST + " " + q("bash -lc " + q(remoteCmd)); - return sh(cmd); - } - - private static void scpStrict(String local, String remote) { - Objects.requireNonNull(local); - Objects.requireNonNull(remote); - int code = sh("scp -p " + q(local) + " " + REMOTE_USER + "@" + REMOTE_HOST + ":" + q(remote)); - if (code != 0) throw new RuntimeException("SCP failed (" + code + ")"); - } - - private static int sh(String cmd) { - try { - Process p = new ProcessBuilder("bash", "-lc", cmd).inheritIO().start(); - return p.waitFor(); - } catch (Exception e) { - throw new RuntimeException("Command error: " + cmd, e); - } - } - - private static String q(String s) { - // простая одинарная кавычка для bash - return "'" + s.replace("'", "'\"'\"'") + "'"; - } - - private static void sleepMs(long ms) { - try { Thread.sleep(ms); } - catch (InterruptedException e) { Thread.currentThread().interrupt(); } - } -} -package test.it; - -import server.ws.WsServer; -import test.it.runner.IT_CleanAllDate; -import test.it.runner.IT_RunAllMain; - -public class IT_RunAllCleanStartWsMain { - - public static void main(String[] args) { - runBash("kill -9 $(lsof -t -i:7070) 2>/dev/null || true"); - - IT_CleanAllDate.main(new String[0]); - - Thread wsThread = new Thread(() -> { - try { - WsServer.main(new String[0]); - } catch (Throwable t) { - t.printStackTrace(System.out); - } - }, "wsServer-thread"); - wsThread.setDaemon(true); - wsThread.start(); - - sleepMs(1000); - - int failed = IT_RunAllMain.runAll(); - System.exit(failed); - } - - private static void runBash(String cmd) { - try { - Process p = new ProcessBuilder("bash", "-lc", cmd).inheritIO().start(); - p.waitFor(); - } catch (Exception e) { - System.out.println("WARN: bash command failed: " + e); - } - } - - private static void sleepMs(long ms) { - try { - Thread.sleep(ms); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - } - } -} -package test.it.runner; - -import test.it.utils.TestConfig; -import test.it.utils.log.TestLog; - -import java.io.IOException; -import java.nio.file.*; -import java.util.Comparator; - -/** - * - * Делает: - * 1) чистит папку data/ - */ -public class IT_CleanAllDate { - - private static final String DATA_DIR = "data"; - - public static void main(String[] args) { -// ItRunContext.initIfNeeded(); - - TestLog.title("IT RUN CLEAN: очистка data/ + запуск всех тестов"); - - try { - cleanupDataDir(DATA_DIR); - } catch (Throwable t) { - TestLog.boom("Не смог очистить data/. Причина: " + t.getMessage()); - if (TestConfig.DEBUG()) t.printStackTrace(System.out); - System.exit(1); - } - - } - - private static void cleanupDataDir(String dirName) throws IOException { - Path dir = Paths.get(dirName); - - if (!Files.exists(dir)) { - TestLog.warn("data dir not found: " + dir.toAbsolutePath() + " (создаю)"); - Files.createDirectories(dir); - return; - } - - // удаляем ВСЁ внутри папки, но саму папку оставляем - Files.walk(dir) - .sorted(Comparator.reverseOrder()) - .filter(p -> !p.equals(dir)) - .forEach(p -> { - try { - Files.deleteIfExists(p); - } catch (IOException e) { - throw new RuntimeException("Не смог удалить: " + p.toAbsolutePath(), e); - } - }); - - TestLog.ok("data очищена: " + dir.toAbsolutePath()); - } -} -package test.it.runner; - -import test.it.cases.IT_01_AddUser; -import test.it.cases.IT_02_Sessions; -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.utils.log.TestLog; - -import java.util.ArrayList; -import java.util.List; - -/** - * Ручной запуск всех IT тестов БЕЗ JUnit. - * Печатает итоги по каждому тесту отдельной строкой. - */ -public class IT_RunAllMain { - - /** - * Настройка поведения прогона: - * - true : остановить запуск сразу после первого упавшего теста - * - false : прогнать все тесты до конца, даже если некоторые упали - */ - private static final boolean STOP_ON_FIRST_FAIL = true; - - public static void main(String[] args) { - int failed = runAll(); - // при желании можно вернуть код выхода ОС: - // System.exit(failed == 0 ? 0 : 1); - } - - public static int runAll() { - - List summaries = new ArrayList<>(); - int failed = 0; - - TestLog.title("IT RUN: запуск всех тестов подряд" - + (STOP_ON_FIRST_FAIL ? " (STOP_ON_FIRST_FAIL=ON)" : " (STOP_ON_FIRST_FAIL=OFF)")); - - String s1 = IT_01_AddUser.run(); summaries.add(s1); - if (s1.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } - - String s2 = IT_02_Sessions.run(); summaries.add(s2); - if (s2.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } - - String s3 = IT_03_AddBlock_NoAuth.run(); summaries.add(s3); - if (s3.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } - - String s4 = IT_04_UserParams_NoAuth.run(); summaries.add(s4); - if (s4.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } - - String s5 = IT_05_UserConnections.run(); summaries.add(s5); - if (s5.contains("FAIL:")) { failed++; if (STOP_ON_FIRST_FAIL) return finishEarly(summaries, failed); } - - return finish(summaries, failed); - } - - private static int finishEarly(List summaries, int failed) { - TestLog.boom("⛔ Остановка прогона: найден FAIL, STOP_ON_FIRST_FAIL=ON"); - return finish(summaries, failed); - } - - private static int finish(List summaries, int failed) { - TestLog.title("IT RUN RESULT (per test)"); - for (String s : summaries) System.out.println(s); - - if (failed == 0) TestLog.ok("\n ВСЕ IT ТЕСТЫ УСПЕШНО ЗАВЕРШЕНЫ"); - else TestLog.boom("❌ IT ПРОГОН УПАЛ: failed=" + failed + " из " + summaries.size()); - - return failed; - } -} -package test.it.suite; - -import org.junit.platform.suite.api.SelectClasses; -import org.junit.platform.suite.api.Suite; -import test.it.cases.IT_01_AddUser; -import test.it.cases.IT_02_Sessions; -import test.it.cases.IT_03_AddBlock_NoAuth; - -/** - * Сьют, который запускает IT тесты строго в заданном порядке. - * - * Запуск: - * ./gradlew test --tests test.it.suite.IT_00_Suite - */ -@Suite -@SelectClasses({ - IT_01_AddUser.class, - IT_02_Sessions.class, - IT_03_AddBlock_NoAuth.class -}) -public class IT_00_Suite { - // пусто -} -package test.it.utils.json; - -import test.it.utils.TestIds; -import test.it.utils.TestConfig; -import utils.crypto.Ed25519Util; - -import java.nio.charset.StandardCharsets; -import java.util.Base64; - -/** Builder'ы JSON запросов. Внутри автоматически генерим requestId. */ -public final class JsonBuilders { - private JsonBuilders() {} - - // ---------------- AddUser ---------------- - - public static String addUser(String login) { - String requestId = TestIds.next("adduser"); - String blockchainName = TestConfig.getBlockchainName(login); - - String solanaKeyB64 = TestConfig.solanaPublicKeyB64(login); - String blockchainKeyB64 = TestConfig.blockchainPublicKeyB64(login); - String deviceKeyB64 = TestConfig.devicePublicKeyB64(login); - - return """ - { - "op": "AddUser", - "requestId": "%s", - "payload": { - "login": "%s", - "blockchainName": "%s", - "solanaKey": "%s", - "blockchainKey": "%s", - "deviceKey": "%s", - "bchLimit": %d - } - } - """.formatted( - requestId, - login, - blockchainName, - solanaKeyB64, - blockchainKeyB64, - deviceKeyB64, - TestConfig.TEST_BCH_LIMIT - ); - } - - // ---------------- GetUser ---------------- - - public static String getUser(String login) { - String requestId = TestIds.next("getuser"); - return """ - { - "op": "GetUser", - "requestId": "%s", - "payload": { - "login": "%s" - } - } - """.formatted(requestId, login); - } - - // ---------------- SearchUsers ---------------- - - public static String searchUsers(String prefix) { - String requestId = TestIds.next("searchusers"); - return """ - { - "op": "SearchUsers", - "requestId": "%s", - "payload": { - "prefix": "%s" - } - } - """.formatted(requestId, prefix); - } - - // ---------------- GetFriendsLists ---------------- - - public static String getFriendsLists(String login) { - String requestId = TestIds.next("friends"); - return """ - { - "op": "GetFriendsLists", - "requestId": "%s", - "payload": { - "login": "%s" - } - } - """.formatted(requestId, login); - } - - // ---------------- AuthChallenge ---------------- - - public static String authChallenge(String login) { - String requestId = TestIds.next("auth"); - return """ - { - "op": "AuthChallenge", - "requestId": "%s", - "payload": { "login": "%s" } - } - """.formatted(requestId, login); - } - - // ---------------- CreateAuthSession (v2) ---------------- - // v2: sessionKey генерируется/хранится на клиенте, на сервер отправляем sessionPubKeyB64 (base64). - // - // ВАЖНО (новое правило): - // Подпись CreateAuthSession делается ТОЛЬКО deviceKey над строкой: - // preimage = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce - // - // storagePwd и sessionPubKeyB64 НЕ входят в preimage. - - public static String createAuthSessionV2(String login, String authNonce, String storagePwd, String sessionPubKeyB64) { - long timeMs = System.currentTimeMillis(); - - // подпись делаем devicePrivKey - byte[] devicePriv = TestConfig.getDevicePrivatKey(login); - String sigB64 = signAuthCreateSession(login, timeMs, authNonce, devicePriv); - - String requestId = TestIds.next("create"); - return """ - { - "op": "CreateAuthSession", - "requestId": "%s", - "payload": { - "storagePwd": "%s", - "sessionPubKeyB64": "%s", - "timeMs": %d, - "signatureB64": "%s", - "clientInfo": "%s" - } - } - """.formatted( - requestId, - storagePwd, - sessionPubKeyB64, - timeMs, - sigB64, - TestConfig.TEST_CLIENT_INFO - ); - } - - // ---------------- SessionChallenge (v2) ---------------- - - public static String sessionChallenge(String sessionId) { - String requestId = TestIds.next("sch"); - return """ - { - "op": "SessionChallenge", - "requestId": "%s", - "payload": { - "sessionId": "%s" - } - } - """.formatted(requestId, sessionId); - } - - // ---------------- SessionLogin (v2) ---------------- - // Подпись SessionLogin по-прежнему делается sessionPrivKey: - // preimage = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce - - public static String sessionLogin(String sessionId, String nonce, byte[] sessionPrivKey) { - long timeMs = System.currentTimeMillis(); - String sigB64 = signSessionLogin(sessionId, timeMs, nonce, sessionPrivKey); - - String requestId = TestIds.next("slogin"); - return """ - { - "op": "SessionLogin", - "requestId": "%s", - "payload": { - "sessionId": "%s", - "timeMs": %d, - "signatureB64": "%s", - "clientInfo": "%s" - } - } - """.formatted(requestId, sessionId, timeMs, sigB64, TestConfig.TEST_CLIENT_INFO); - } - - // ---------------- ListSessions ---------------- - - public static String listSessions(long timeMs, String signatureB64) { - String requestId = TestIds.next("list"); - if (signatureB64 == null) signatureB64 = ""; - return """ - { - "op": "ListSessions", - "requestId": "%s", - "payload": { - } - } - """.formatted(requestId, timeMs, signatureB64); - } - - // ---------------- CloseActiveSession ---------------- - - public static String closeActiveSession(String sessionId, long timeMs, String signatureB64) { - String requestId = TestIds.next("close"); - if (signatureB64 == null) signatureB64 = ""; - return """ - { - "op": "CloseActiveSession", - "requestId": "%s", - "payload": { - "sessionId": "%s" - } - } - """.formatted(requestId, sessionId, timeMs, signatureB64); - } - - // ---------------- ListSubscribedChannels ---------------- - - public static String listSubscribedChannels(String login) { - String requestId = TestIds.next("subs"); - return """ - { - "op": "ListSubscribedChannels", - "requestId": "%s", - "payload": { "login": "%s" } - } - """.formatted(requestId, login); - } - - /** - * Подпись CreateAuthSession(v2): - * preimage = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce - * подписываем devicePrivKey. - */ - public static String signAuthCreateSession(String login, long timeMs, String authNonce, byte[] devicePrivKey) { - String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + timeMs + ":" + authNonce; - byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); - byte[] sig = Ed25519Util.sign(preimage, devicePrivKey); - return Base64.getEncoder().encodeToString(sig); - } - - /** - * Подпись для SessionLogin(v2): - * preimage = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce - * подписываем sessionPrivKey. - */ - public static String signSessionLogin(String sessionId, long timeMs, String nonce, byte[] sessionPrivKey) { - String preimageStr = "SESSION_LOGIN:" + sessionId + ":" + timeMs + ":" + nonce; - byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); - byte[] sig = Ed25519Util.sign(preimage, sessionPrivKey); - return Base64.getEncoder().encodeToString(sig); - } -} -package test.it.utils.json; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -import java.util.ArrayList; -import java.util.List; - -public final class JsonParsers { - private JsonParsers(){} - private static final ObjectMapper MAPPER = new ObjectMapper(); - - public static int status(String json) { - try { - JsonNode root = MAPPER.readTree(json); - return root.has("status") ? root.get("status").asInt() : -1; - } catch (Exception e) { - return -1; - } - } - - public static String authNonce(String json) { - try { - JsonNode root = MAPPER.readTree(json); - JsonNode payload = root.get("payload"); - if (payload != null && payload.has("authNonce")) return payload.get("authNonce").asText(); - return null; - } catch (Exception e) { - return null; - } - } - - /** nonce из SessionChallenge(v2) */ - public static String sessionNonce(String json) { - try { - JsonNode root = MAPPER.readTree(json); - JsonNode payload = root.get("payload"); - if (payload != null && payload.has("nonce")) return payload.get("nonce").asText(); - return null; - } catch (Exception e) { - return null; - } - } - - public static String sessionId(String json) { - try { - JsonNode root = MAPPER.readTree(json); - JsonNode payload = root.get("payload"); - if (payload != null && payload.has("sessionId")) return payload.get("sessionId").asText(); - return null; - } catch (Exception e) { - return null; - } - } - - // оставляю для совместимости с другими тестами, но в IT_02(v2) больше не используется - public static String sessionPwd(String json) { - try { - JsonNode root = MAPPER.readTree(json); - JsonNode payload = root.get("payload"); - if (payload != null && payload.has("sessionPwd")) return payload.get("sessionPwd").asText(); - return null; - } catch (Exception e) { - return null; - } - } - - public static String storagePwd(String json) { - try { - JsonNode root = MAPPER.readTree(json); - JsonNode payload = root.get("payload"); - if (payload != null && payload.has("storagePwd")) return payload.get("storagePwd").asText(); - return null; - } catch (Exception e) { - return null; - } - } - - public static List sessionIds(String json) { - List res = new ArrayList<>(); - try { - JsonNode root = MAPPER.readTree(json); - JsonNode payload = root.get("payload"); - if (payload == null) return res; - JsonNode arr = payload.get("sessions"); - if (arr == null || !arr.isArray()) return res; - - for (JsonNode s : arr) { - JsonNode id = s.get("sessionId"); - if (id != null && !id.isNull()) res.add(id.asText()); - } - } catch (Exception ignored) {} - return res; - } - - public static String errorCode(String json) { - try { - JsonNode root = MAPPER.readTree(json); - - // поддержка старого формата (верхний уровень) - if (root.has("errorCode")) return root.get("errorCode").asText(); - // поддержка нового формата (верхний уровень) - if (root.has("code")) return root.get("code").asText(); - - JsonNode payload = root.get("payload"); - if (payload != null) { - // поддержка старого формата (внутри payload) - if (payload.has("errorCode")) return payload.get("errorCode").asText(); - // поддержка нового формата (внутри payload) - if (payload.has("code")) return payload.get("code").asText(); - } - } catch (Exception ignored) {} - - return null; - } - - // ---------------- GetUser helpers ---------------- - - public static Boolean exists(String json) { - try { - JsonNode root = MAPPER.readTree(json); - JsonNode payload = root.get("payload"); - if (payload != null && payload.has("exists")) return payload.get("exists").asBoolean(); - return null; - } catch (Exception e) { - return null; - } - } - - public static String userLogin(String json) { - return getPayloadText(json, "login"); - } - - public static String userBlockchainName(String json) { - return getPayloadText(json, "blockchainName"); - } - - public static String userSolanaKey(String json) { - return getPayloadText(json, "solanaKey"); - } - - public static String userBlockchainKey(String json) { - return getPayloadText(json, "blockchainKey"); - } - - public static String userDeviceKey(String json) { - return getPayloadText(json, "deviceKey"); - } - - // ---------------- SearchUsers helpers ---------------- - - public static List searchLogins(String json) { - List res = new ArrayList<>(); - try { - JsonNode root = MAPPER.readTree(json); - JsonNode payload = root.get("payload"); - if (payload == null) return res; - - JsonNode arr = payload.get("logins"); - if (arr == null || !arr.isArray()) return res; - - for (JsonNode x : arr) { - if (x != null && !x.isNull()) res.add(x.asText()); - } - } catch (Exception ignored) {} - return res; - } - - // ---------------- Friends helpers ---------------- - - /** payload.login (канонический) */ - public static String friendsLogin(String json) { - return getPayloadText(json, "login"); - } - - public static List friendsOut(String json) { - return getPayloadStringArray(json, "out_friends"); - } - - public static List friendsIn(String json) { - return getPayloadStringArray(json, "in_friends"); - } - - public static List friendsMutual(String json) { - return getPayloadStringArray(json, "mutual_friends"); - } - - private static List getPayloadStringArray(String json, String field) { - List res = new ArrayList<>(); - try { - JsonNode root = MAPPER.readTree(json); - JsonNode payload = root.get("payload"); - if (payload == null) return res; - - JsonNode arr = payload.get(field); - if (arr == null || !arr.isArray()) return res; - - for (JsonNode x : arr) { - if (x != null && !x.isNull()) res.add(x.asText()); - } - } catch (Exception ignored) {} - return res; - } - - private static String getPayloadText(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).asText(); - } - return null; - } catch (Exception e) { - return null; - } - } -} -package test.it.utils.log; - -import test.it.utils.TestConfig; - -/** - * TestLog — единое место для: - * - ANSI цветов - * - стандартных сообщений (title/step/send/recv) - * - PASS/FAIL строк и окраски - * - * Режим: - * - it.debug=false: печатаем минимум (без JSON) - * - it.debug=true: печатаем JSON отправка/ответ + заголовки шагов - */ -public final class TestLog { - private TestLog() {} - - public static final boolean DEBUG = TestConfig.DEBUG(); - - // ANSI COLORS (ТОЛЬКО ТУТ) - public static final String R = "\u001B[0m"; - public static final String G = "\u001B[32m"; - public static final String Y = "\u001B[33m"; - public static final String RED = "\u001B[31m"; - public static final String C = "\u001B[36m"; - - public static String green(String s) { return G + s + R; } - public static String red(String s) { return RED + s + R; } - public static String cyan(String s) { return C + s + R; } - - /** Инфо (печатается только при DEBUG=true). */ - public static void info(String s) { - if (DEBUG) System.out.println(s); - } - - public static void line() { - if (!DEBUG) return; - System.out.println(C + "------------------------------------------------------------" + R); - } - - public static void title(String s) { - if (!DEBUG) return; - System.out.println(C + "\n============================================================" + R); - System.out.println(C + s + R); - System.out.println(C + "============================================================\n" + R); - } - - public static void titleBlock(String multiLineText) { - if (!DEBUG) return; - System.out.println(C + "\n============================================================" + R); - System.out.println(C + multiLineText + R); - System.out.println(C + "============================================================\n" + R); - } - - public static void stepTitle(String s) { - if (!DEBUG) return; - System.out.println(C + "\n-------------------- " + s + " --------------------" + R); - } - - /** OK (печатаем ВСЕГДА, чтобы было видно зелёное прохождение шагов). */ - public static void ok(String s) { - System.out.println(G + "✅ " + s + R); - } - - /** WARN (только DEBUG). */ - public static void warn(String s) { - if (!DEBUG) return; - System.out.println(Y + "⚠️ " + s + R); - } - - /** FAIL (печатаем ВСЕГДА). */ - public static void boom(String s) { - System.out.println(RED + "****************************************************************" + R); - System.out.println(RED + "❌ " + s + R); - System.out.println(RED + "****************************************************************" + R); - } - - public static void send(String op, String json) { - if (!DEBUG) return; - System.out.println("📤 [" + op + "] Request JSON:"); - System.out.println(json); - line(); - } - - public static void recv(String op, String json) { - if (!DEBUG) return; - System.out.println("📥 [" + op + "] Response JSON:"); - System.out.println(json); - line(); - } -} -package test.it.utils.log; - -import java.util.ArrayList; -import java.util.List; - -/** - * TestResult — накопитель результатов внутри одного теста: - * - ok(...) печатает зелёным - * - fail(...) печатает красным и добавляет в итоговую строку - * - summaryLine() возвращает одну строку: PASS/FAIL + детали - */ -public final class TestResult { - - private final String testName; - private final List errors = new ArrayList<>(); - - public TestResult(String testName) { - this.testName = testName; - } - - public void ok(String msg) { - TestLog.ok(msg); - } - - public void fail(String msg) { - errors.add(msg); - TestLog.boom(msg); - } - - public boolean isOk() { - return errors.isEmpty(); - } - - public String summaryLine() { - if (errors.isEmpty()) { - return TestLog.green("PASS: " + testName + " — OK"); - } - StringBuilder sb = new StringBuilder(); - sb.append(TestLog.red("FAIL: ")).append(testName).append(" — ").append(errors.size()).append(" ошибок: "); - for (int i = 0; i < errors.size(); i++) { - if (i > 0) sb.append(" | "); - sb.append(errors.get(i)); - } - return sb.toString(); - } -} -package test.it.utils; - -import utils.crypto.Ed25519Util; - -import java.util.Base64; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -/** - * TestConfig — конфиг IT тестов: - * - 3 пользователя (TestUser1/2/3) - * - ключи по login через map (device/solana/blockchain/session) - * - blockchainName = login + "-" + "001" - * - * Важно: - * - privateKey = Ed25519Util.generatePrivateKeyFromString(seed) (sha256(seed) => 32 bytes) - * - publicKey = Ed25519Util.derivePublicKey(privateKey) - * - device/solana/blockchain ключи пока одинаковые (seed = login) - * - session ключ отдельный (seed = "session:" + login) — чтобы SessionLogin был честнее. - */ -public final class TestConfig { - - private TestConfig() {} - - public static final String WS_URI_LOCAL = "ws://localhost:7070/ws"; - public static final String WS_URI_Server = "wss://shineup.me/ws"; - - // по умолчанию LOCAL, но можно переопределить: -Dit.wsUri=... - public static final String WS_URI = System.getProperty("it.wsUri", WS_URI_LOCAL); - - public static final long TEST_BCH_LIMIT = 50_000_000L; - public static final String TEST_CLIENT_INFO = "it-tests"; - - public static boolean DEBUG() { - return Boolean.parseBoolean(System.getProperty("it.debug", "true")); - } - - // 3 users - public static final String DEFAULT_LOGIN1 = "TestUser1"; - public static final String DEFAULT_LOGIN2 = "TestUser2"; - public static final String DEFAULT_LOGIN3 = "TestUser3"; - public static final String DEFAULT_BCH_SUFFIX_3 = "001"; - - public static String LOGIN() { return System.getProperty("it.login1", DEFAULT_LOGIN1); } - public static String LOGIN2() { return System.getProperty("it.login2", DEFAULT_LOGIN2); } - public static String LOGIN3() { return System.getProperty("it.login3", DEFAULT_LOGIN3); } - - public static String BCH_SUFFIX_3() { - return System.getProperty("it.bchSuffix", DEFAULT_BCH_SUFFIX_3); - } - - public static String getBlockchainName(String login) { - if (login == null) throw new IllegalArgumentException("login is null"); - return login + "-" + BCH_SUFFIX_3(); - } - - // ============ key maps ============ - private static final Map devicePriv = new ConcurrentHashMap<>(); - private static final Map devicePub = new ConcurrentHashMap<>(); - - private static final Map solanaPriv = new ConcurrentHashMap<>(); - private static final Map solanaPub = new ConcurrentHashMap<>(); - - private static final Map bchPriv = new ConcurrentHashMap<>(); - private static final Map bchPub = new ConcurrentHashMap<>(); - - // NEW: session keys (для SessionLogin v2) - private static final Map sessionPriv = new ConcurrentHashMap<>(); - private static final Map sessionPub = new ConcurrentHashMap<>(); - - static { - initUserKeys(LOGIN()); - initUserKeys(LOGIN2()); - initUserKeys(LOGIN3()); - } - - private static void initUserKeys(String login) { - // seed = login - byte[] priv = Ed25519Util.generatePrivateKeyFromString(login); // sha256(login) => 32 bytes - byte[] pub = Ed25519Util.derivePublicKey(priv); - - // пока одинаковые - devicePriv.put(login, priv); - devicePub.put(login, pub); - - solanaPriv.put(login, priv); - solanaPub.put(login, pub); - - bchPriv.put(login, priv); - bchPub.put(login, pub); - - // session seed = "session:" + login (отдельно!) - byte[] sPriv = Ed25519Util.generatePrivateKeyFromString("session:" + login); - byte[] sPub = Ed25519Util.derivePublicKey(sPriv); - - sessionPriv.put(login, sPriv); - sessionPub.put(login, sPub); - } - - // ============ requested getters (with your names) ============ - - public static byte[] getDevicePrivatKey(String login) { return cloneOrThrow(devicePriv.get(login), "devicePriv", login); } - public static byte[] getDevicePublicKey(String login) { return cloneOrThrow(devicePub.get(login), "devicePub", login); } - - public static byte[] getSolanaPrivatKey(String login) { return cloneOrThrow(solanaPriv.get(login), "solanaPriv", login); } - public static byte[] getSolanaPublicKey(String login) { return cloneOrThrow(solanaPub.get(login), "solanaPub", login); } - - public static byte[] getBlockchainPrivatKey(String login) { return cloneOrThrow(bchPriv.get(login), "bchPriv", login); } - public static byte[] getBlockchainPublicKey(String login) { return cloneOrThrow(bchPub.get(login), "bchPub", login); } - - // NEW: session getters - public static byte[] getSessionPrivatKey(String login) { return cloneOrThrow(sessionPriv.get(login), "sessionPriv", login); } - public static byte[] getSessionPublicKey(String login) { return cloneOrThrow(sessionPub.get(login), "sessionPub", login); } - - // ============ base64 helpers ============ - public static String devicePublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getDevicePublicKey(login)); } - public static String solanaPublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getSolanaPublicKey(login)); } - public static String blockchainPublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getBlockchainPublicKey(login)); } - - // NEW: session pub b64 helper - public static String sessionPublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getSessionPublicKey(login)); } - - // ============ backward-compatible helpers for "user1" ============ - public static String BCH_NAME() { return getBlockchainName(LOGIN()); } - public static String BCH_NAME2() { return getBlockchainName(LOGIN2()); } - public static String BCH_NAME3() { return getBlockchainName(LOGIN3()); } - - /** solanaKey для AddUser: публичный ключ Solana-пользователя */ - public static String SOLANA_PUBKEY_B64() { return solanaPublicKeyB64(LOGIN()); } - public static String SOLANA2_PUBKEY_B64() { return solanaPublicKeyB64(LOGIN2()); } - public static String SOLANA3_PUBKEY_B64() { return solanaPublicKeyB64(LOGIN3()); } - - /** blockchainKey для AddUser: публичный ключ блокчейна */ - public static String BLOCKCHAIN_PUBKEY_B64() { return blockchainPublicKeyB64(LOGIN()); } - public static String BLOCKCHAIN2_PUBKEY_B64() { return blockchainPublicKeyB64(LOGIN2()); } - public static String BLOCKCHAIN3_PUBKEY_B64() { return blockchainPublicKeyB64(LOGIN3()); } - - public static String DEVICE_PUBKEY_B64() { return devicePublicKeyB64(LOGIN()); } - public static String DEVICE2_PUBKEY_B64() { return devicePublicKeyB64(LOGIN2()); } - public static String DEVICE3_PUBKEY_B64() { return devicePublicKeyB64(LOGIN3()); } - - // NEW: session pub b64 compat - public static String SESSION_PUBKEY_B64() { return sessionPublicKeyB64(LOGIN()); } - public static String SESSION2_PUBKEY_B64() { return sessionPublicKeyB64(LOGIN2()); } - public static String SESSION3_PUBKEY_B64() { return sessionPublicKeyB64(LOGIN3()); } - - // ============ misc ============ - public static String fakeStoragePwd() { - return "pwd-" + System.nanoTime(); - } - - private static byte[] cloneOrThrow(byte[] v, String mapName, String login) { - if (login == null) throw new IllegalArgumentException("login is null"); - if (v == null) throw new IllegalStateException("No key in " + mapName + " for login=" + login); - return v.clone(); - } -} -package test.it.utils; - -import java.util.concurrent.atomic.AtomicLong; - -/** Генератор уникальных requestId для IT тестов (в пределах одной JVM). */ -public final class TestIds { - private static final AtomicLong SEQ = new AtomicLong(0); - - private TestIds() {} - - public static String next(String prefix) { - long n = SEQ.incrementAndGet(); - return "it-" + (prefix == null ? "req" : prefix) + "-" + n; - } -} -package test.it.utils.ws; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import test.it.utils.TestConfig; -import test.it.utils.log.TestLog; - -import java.time.Duration; - -/** - * WsSession — одно WS соединение на много запросов. - * - * Использование в тесте: - * try (WsSession ws = WsSession.open()) { - * String resp = ws.call("AuthChallenge", JsonBuilders.authChallenge(login), t); - * } - */ -public final class WsSession implements AutoCloseable { - - private static final ObjectMapper M = new ObjectMapper(); - - private final WsTestClient client; - - private WsSession(WsTestClient client) { - this.client = client; - } - - public static WsSession open() { - return new WsSession(new WsTestClient(TestConfig.WS_URI)); - } - - /** Отправить JSON (в котором уже есть requestId) и получить JSON ответ строкой. */ - public String call(String op, String requestJson, Duration timeout) { - String requestId = extractRequestId(requestJson); - if (requestId == null || requestId.isBlank()) throw new IllegalArgumentException("requestJson must contain requestId: " + requestJson); - - if (TestConfig.DEBUG()) TestLog.send(op, requestJson); - - String resp = client.request(requestId, requestJson, timeout); - - if (TestConfig.DEBUG()) TestLog.recv(op, resp); - - return resp; - } - - private static String extractRequestId(String json) { - try { - JsonNode root = M.readTree(json); - JsonNode id = root.get("requestId"); - return (id == null || id.isNull()) ? null : id.asText(); - } catch (Exception e) { - return null; - } - } - - @Override - public void close() { - client.close(); - } -} -package test.it.utils.ws; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.WebSocket; -import java.time.Duration; -import java.util.Map; -import java.util.concurrent.*; - -public final class WsTestClient implements AutoCloseable { - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - private final WebSocket ws; - private final Map> pending = new ConcurrentHashMap<>(); - - public WsTestClient(String wsUri) { - HttpClient client = HttpClient.newHttpClient(); - this.ws = client.newWebSocketBuilder() - .connectTimeout(Duration.ofSeconds(5)) - .buildAsync(URI.create(wsUri), new WebSocket.Listener() { - @Override - public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { - String msg = data.toString(); - String requestId = extractRequestId(msg); - if (requestId != null) { - CompletableFuture f = pending.remove(requestId); - if (f != null) f.complete(msg); - } - webSocket.request(1); - return CompletableFuture.completedFuture(null); - } - - @Override - public void onError(WebSocket webSocket, Throwable error) { - // Завалим все ожидания, чтобы тест корректно упал - pending.forEach((k, f) -> f.completeExceptionally(error)); - pending.clear(); - } - }).join(); - - this.ws.request(1); - } - - public String request(String requestId, String json, Duration timeout) { - CompletableFuture fut = new CompletableFuture<>(); - pending.put(requestId, fut); - ws.sendText(json, true); - try { - return fut.get(timeout.toMillis(), TimeUnit.MILLISECONDS); - } catch (Exception e) { - pending.remove(requestId); - throw new RuntimeException("Timeout/Fail waiting response requestId=" + requestId, e); - } - } - - private static String extractRequestId(String json) { - try { - JsonNode root = MAPPER.readTree(json); - JsonNode id = root.get("requestId"); - return id != null && !id.isNull() ? id.asText() : null; - } catch (Exception ignored) { - return null; - } - } - - @Override - public void close() { - try { - ws.sendClose(WebSocket.NORMAL_CLOSURE, "bye").join(); - } catch (Exception ignored) {} - } -} diff --git a/SHiNE-server/src/test/concat_to_file.sh b/SHiNE-server/src/test/concat_to_file.sh deleted file mode 100755 index f6db1f1..0000000 --- a/SHiNE-server/src/test/concat_to_file.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -OUTFILE="all_files.txt" - -# очищаем или создаём файл -: > "$OUTFILE" - -# собрать только *.java файлы и вывести их содержимое в файл -find . -type f -name "*.java" | sort | while read -r f; do - cat "$f" >> "$OUTFILE" - echo >> "$OUTFILE" # пустая строка-разделитель -done - -# скопировать весь файл в буфер обмена (Wayland) -wl-copy < "$OUTFILE" - -echo "Готово!" -echo "Все .java файлы собраны в $OUTFILE" -echo "Содержимое скопировано в буфер обмена (Wayland)" diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_01_AddUser.java b/SHiNE-server/src/test/java/test/it/cases/IT_01_AddUser.java index f621348..cd13273 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_01_AddUser.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_01_AddUser.java @@ -160,9 +160,9 @@ public class IT_01_AddUser { String blockchainName = JsonParsers.userBlockchainName(resp); String solanaKey = JsonParsers.userSolanaKey(resp); String blockchainKey = JsonParsers.userBlockchainKey(resp); - String deviceKey = JsonParsers.userDeviceKey(resp); + String clientKey = JsonParsers.userClientKey(resp); - if (isBlank(login) || isBlank(blockchainName) || isBlank(solanaKey) || isBlank(blockchainKey) || isBlank(deviceKey)) { + if (isBlank(login) || isBlank(blockchainName) || isBlank(solanaKey) || isBlank(blockchainKey) || isBlank(clientKey)) { r.fail("GetUser: exists=true, но поля пустые/неполные, resp=" + resp); fail("GetUser returned incomplete user data"); } @@ -187,7 +187,7 @@ public class IT_01_AddUser { // ключи должны совпадать с теми, что AddUser использует при регистрации String expSol = TestConfig.solanaPublicKeyB64(canonical); String expBchKey = TestConfig.blockchainPublicKeyB64(canonical); - String expDev = TestConfig.devicePublicKeyB64(canonical); + String expDev = TestConfig.clientPublicKeyB64(canonical); if (!solanaKey.equals(expSol)) { r.fail("GetUser: solanaKey mismatch, resp=" + resp); @@ -197,9 +197,9 @@ public class IT_01_AddUser { r.fail("GetUser: blockchainKey mismatch, resp=" + resp); fail("GetUser blockchainKey mismatch"); } - if (!deviceKey.equals(expDev)) { - r.fail("GetUser: deviceKey mismatch, resp=" + resp); - fail("GetUser deviceKey mismatch"); + if (!clientKey.equals(expDev)) { + r.fail("GetUser: clientKey mismatch, resp=" + resp); + fail("GetUser clientKey mismatch"); } } @@ -270,7 +270,7 @@ public class IT_01_AddUser { "blockchainName": "%s", "solanaKey": "%s", "blockchainKey": "%s", - "deviceKey": "%s", + "clientKey": "%s", "bchLimit": %d } } diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_02_Sessions.java b/SHiNE-server/src/test/java/test/it/cases/IT_02_Sessions.java index 27da4fc..adde6f7 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_02_Sessions.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_02_Sessions.java @@ -23,7 +23,7 @@ import static org.junit.jupiter.api.Assertions.*; * - и после завершения оставить в БД 3 активных сессии (S1,S2,S3) * * Протокол v2: - * - создание сессии: AuthChallenge -> CreateAuthSession (deviceKey подпись, + deviceKey + sessionKey) + * - создание сессии: AuthChallenge -> CreateAuthSession (clientKey подпись, + clientKey + sessionKey) * - вход в сессию: SessionChallenge(sessionId) -> nonce, затем SessionLogin(sessionId,time,signature(sessionKey)) * - ListSessions и CloseActiveSession доступны только в AUTH_STATUS_USER (после SessionLogin) */ @@ -122,7 +122,7 @@ public class IT_02_Sessions { // storagePwd на клиенте (сохраняем, чтобы потом проверить, что сервер вернул именно его) String storagePwd = TestConfig.fakeStoragePwd(); - // шаг 2: CreateAuthSession (device подпись + deviceKey + sessionKey) + // шаг 2: CreateAuthSession (device подпись + clientKey + sessionKey) String createResp = ws.call( "CreateAuthSession(" + label + ")", JsonBuilders.createAuthSessionV2(login, authNonce, storagePwd, sessionMaterial.sessionKey()), @@ -194,7 +194,7 @@ public class IT_02_Sessions { "storagePwd": "%s", "timeMs": %d, "authNonce": "%s", - "deviceKey": "%s", + "clientKey": "%s", "signatureB64": "%s", "clientInfo": "%s" } diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_04_UserParams_NoAuth.java b/SHiNE-server/src/test/java/test/it/cases/IT_04_UserParams_NoAuth.java index 68fc1cb..3f050f8 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_04_UserParams_NoAuth.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_04_UserParams_NoAuth.java @@ -40,15 +40,15 @@ public class IT_04_UserParams_NoAuth { Duration timeout = Duration.ofSeconds(5); final String login = TestConfig.LOGIN(); - final String deviceKeyB64 = TestConfig.devicePublicKeyB64(login); - final byte[] devicePrivKey = TestConfig.getDevicePrivatKey(login); + final String clientKeyB64 = TestConfig.clientPublicKeyB64(login); + final byte[] clientPrivKey = TestConfig.getDevicePrivatKey(login); try { // 1) сохранить param1 final String p1 = "profile:name"; final String v1 = "Anna"; final long t1 = System.currentTimeMillis(); - upsertUserParam_OK(r, login, p1, t1, v1, deviceKeyB64, devicePrivKey, timeout); + upsertUserParam_OK(r, login, p1, t1, v1, clientKeyB64, clientPrivKey, timeout); // 2) получить param1 и проверить NetParam got1 = getUserParam_200(r, login, p1, timeout); @@ -56,7 +56,7 @@ public class IT_04_UserParams_NoAuth { assertEquals(p1, got1.param); assertEquals(t1, got1.timeMs); assertEquals(v1, got1.value); - assertEquals(deviceKeyB64, got1.deviceKeyB64); + assertEquals(clientKeyB64, got1.clientKeyB64); assertNotNull(got1.signatureB64); assertFalse(got1.signatureB64.isBlank()); r.ok("GetUserParam(param1) OK"); @@ -65,12 +65,12 @@ public class IT_04_UserParams_NoAuth { final String p2 = "profile:city"; final String v2 = "Amsterdam"; final long t2 = t1 + 10; - upsertUserParam_OK(r, login, p2, t2, v2, deviceKeyB64, devicePrivKey, timeout); + upsertUserParam_OK(r, login, p2, t2, v2, clientKeyB64, clientPrivKey, timeout); // 4) обновить param1 final String v1b = "Anna Updated"; final long t1b = t2 + 10; - upsertUserParam_OK(r, login, p1, t1b, v1b, deviceKeyB64, devicePrivKey, timeout); + upsertUserParam_OK(r, login, p1, t1b, v1b, clientKeyB64, clientPrivKey, timeout); NetParam got1b = getUserParam_200(r, login, p1, timeout); assertEquals(t1b, got1b.timeMs); @@ -92,8 +92,8 @@ public class IT_04_UserParams_NoAuth { assertEquals(t2, lp2.timeMs); assertEquals(v2, lp2.value); - assertEquals(deviceKeyB64, lp1.deviceKeyB64); - assertEquals(deviceKeyB64, lp2.deviceKeyB64); + assertEquals(clientKeyB64, lp1.clientKeyB64); + assertEquals(clientKeyB64, lp2.clientKeyB64); assertNotNull(lp1.signatureB64); assertNotNull(lp2.signatureB64); @@ -110,8 +110,8 @@ public class IT_04_UserParams_NoAuth { // WS helpers: Upsert/Get/List // ================================================================================= - private static void upsertUserParam_OK(TestResult r, String login, String param, long timeMs, String value, String deviceKeyB64, byte[] devicePrivKey, Duration timeout) { - String signatureB64 = signUserParam(devicePrivKey, login, param, timeMs, value); + private static void upsertUserParam_OK(TestResult r, String login, String param, long timeMs, String value, String clientKeyB64, byte[] clientPrivKey, Duration timeout) { + String signatureB64 = signUserParam(clientPrivKey, login, param, timeMs, value); String reqJson = """ { @@ -122,11 +122,11 @@ public class IT_04_UserParams_NoAuth { "param": "%s", "time_ms": %d, "value": "%s", - "device_key": "%s", + "client_key": "%s", "signature": "%s" } } - """.formatted(TestIds.next("upsert"), login, param, timeMs, jsonEscape(value), deviceKeyB64, signatureB64); + """.formatted(TestIds.next("upsert"), login, param, timeMs, jsonEscape(value), clientKeyB64, signatureB64); try (WsSession ws = WsSession.open()) { String resp = ws.call("UpsertUserParam(" + param + ")", reqJson, timeout); @@ -187,7 +187,7 @@ public class IT_04_UserParams_NoAuth { p.param = text(payload, "param"); p.timeMs = longVal(payload, "time_ms"); p.value = text(payload, "value"); - p.deviceKeyB64 = text(payload, "device_key"); + p.clientKeyB64 = text(payload, "client_key"); p.signatureB64 = text(payload, "signature"); return p; } catch (Exception e) { @@ -214,7 +214,7 @@ public class IT_04_UserParams_NoAuth { p.param = text(it, "param"); p.timeMs = longVal(it, "time_ms"); p.value = text(it, "value"); - p.deviceKeyB64 = text(it, "device_key"); + p.clientKeyB64 = text(it, "client_key"); p.signatureB64 = text(it, "signature"); out.items = out.itemsAppend(p); } @@ -239,10 +239,10 @@ public class IT_04_UserParams_NoAuth { // Signature + JSON helpers // ================================================================================= - private static String signUserParam(byte[] devicePrivKey, String login, String param, long timeMs, String value) { + private static String signUserParam(byte[] clientPrivKey, String login, String param, long timeMs, String value) { String signText = ShineSignatureConstants.USER_PARAMETER_PREFIX + login + param + timeMs + value; byte[] signBytes = signText.getBytes(StandardCharsets.UTF_8); - byte[] sig64 = Ed25519Util.sign(signBytes, devicePrivKey); + byte[] sig64 = Ed25519Util.sign(signBytes, clientPrivKey); return Base64.getEncoder().encodeToString(sig64); } @@ -260,7 +260,7 @@ public class IT_04_UserParams_NoAuth { String param; long timeMs; String value; - String deviceKeyB64; + String clientKeyB64; String signatureB64; } 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 index 8c77114..dcaaff3 100644 --- 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 @@ -216,7 +216,7 @@ public class IT_07_EspPairing { entry.setBlockchainName(TestConfig.getBlockchainName(LOGIN)); entry.setSolanaKey(TestConfig.solanaPublicKeyB64(LOGIN)); entry.setBlockchainKey(TestConfig.blockchainPublicKeyB64(LOGIN)); - entry.setDeviceKey(TestConfig.devicePublicKeyB64(LOGIN)); + entry.setClientKey(TestConfig.clientPublicKeyB64(LOGIN)); SolanaUsersDAO.getInstance().insert(entry); } diff --git a/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java b/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java index 632fe50..9b6bcd0 100644 --- a/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java +++ b/SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java @@ -170,7 +170,7 @@ public final class SeedDataPopulationHelper { "blockchainName": "%s", "solanaKey": "%s", "blockchainKey": "%s", - "deviceKey": "%s", + "clientKey": "%s", "bchLimit": 50000000 } } @@ -180,7 +180,7 @@ public final class SeedDataPopulationHelper { bch(login), keys.solanaPublicB64, keys.blockchainPublicB64, - keys.devicePublicB64 + keys.clientPublicB64 ); String resp = ws.call("AddUser#" + login, req, timeout); @@ -244,12 +244,11 @@ public final class SeedDataPopulationHelper { } private static UserKeys deriveKeysFromPassword(String password) { - byte[] base = HashSHA256Util.sha256(password.getBytes(StandardCharsets.UTF_8)); - String baseB64 = Base64.getEncoder().encodeToString(base); + byte[] masterSecret = HashSHA256Util.sha256(password.getBytes(StandardCharsets.UTF_8)); - byte[] rootPriv = HashSHA256Util.sha256((baseB64 + "root.key").getBytes(StandardCharsets.UTF_8)); - byte[] bchPriv = HashSHA256Util.sha256((baseB64 + "bch.key").getBytes(StandardCharsets.UTF_8)); - byte[] devPriv = HashSHA256Util.sha256((baseB64 + "dev.key").getBytes(StandardCharsets.UTF_8)); + byte[] rootPriv = deriveSeed(masterSecret, "root.key"); + byte[] bchPriv = deriveSeed(masterSecret, "blockchain.key"); + byte[] devPriv = deriveSeed(masterSecret, "client.key"); String rootPubB64 = Base64.getEncoder().encodeToString(Ed25519Util.derivePublicKey(rootPriv)); String bchPubB64 = Base64.getEncoder().encodeToString(Ed25519Util.derivePublicKey(bchPriv)); @@ -258,6 +257,21 @@ public final class SeedDataPopulationHelper { return new UserKeys(rootPubB64, bchPubB64, devPubB64, bchPriv); } + private static byte[] deriveSeed(byte[] masterSecret, String suffix) { + byte[] prefix = "SHiNE-key".getBytes(StandardCharsets.UTF_8); + byte[] suffixBytes = suffix.getBytes(StandardCharsets.UTF_8); + byte[] material = new byte[prefix.length + 1 + masterSecret.length + 1 + suffixBytes.length]; + int offset = 0; + System.arraycopy(prefix, 0, material, offset, prefix.length); + offset += prefix.length; + material[offset++] = 0; + System.arraycopy(masterSecret, 0, material, offset, masterSecret.length); + offset += masterSecret.length; + material[offset++] = 0; + System.arraycopy(suffixBytes, 0, material, offset, suffixBytes.length); + return HashSHA256Util.sha256(material); + } + public static final class UserSpec { public final String login; public final String firstName; @@ -292,16 +306,16 @@ public final class SeedDataPopulationHelper { private static final class UserKeys { final String solanaPublicB64; final String blockchainPublicB64; - final String devicePublicB64; + final String clientPublicB64; final byte[] blockchainPrivate32; private UserKeys(String solanaPublicB64, String blockchainPublicB64, - String devicePublicB64, + String clientPublicB64, byte[] blockchainPrivate32) { this.solanaPublicB64 = solanaPublicB64; this.blockchainPublicB64 = blockchainPublicB64; - this.devicePublicB64 = devicePublicB64; + this.clientPublicB64 = clientPublicB64; this.blockchainPrivate32 = blockchainPrivate32; } } diff --git a/SHiNE-server/src/test/java/test/it/utils/TestConfig.java b/SHiNE-server/src/test/java/test/it/utils/TestConfig.java index e461f21..5554a2c 100644 --- a/SHiNE-server/src/test/java/test/it/utils/TestConfig.java +++ b/SHiNE-server/src/test/java/test/it/utils/TestConfig.java @@ -55,8 +55,8 @@ public final class TestConfig { } // ============ key maps ============ - private static final Map devicePriv = new ConcurrentHashMap<>(); - private static final Map devicePub = new ConcurrentHashMap<>(); + private static final Map clientPriv = new ConcurrentHashMap<>(); + private static final Map clientPub = new ConcurrentHashMap<>(); private static final Map solanaPriv = new ConcurrentHashMap<>(); private static final Map solanaPub = new ConcurrentHashMap<>(); @@ -80,8 +80,8 @@ public final class TestConfig { byte[] pub = Ed25519Util.derivePublicKey(priv); // пока одинаковые - devicePriv.put(login, priv); - devicePub.put(login, pub); + clientPriv.put(login, priv); + clientPub.put(login, pub); solanaPriv.put(login, priv); solanaPub.put(login, pub); @@ -99,8 +99,8 @@ public final class TestConfig { // ============ requested getters (with your names) ============ - public static byte[] getDevicePrivatKey(String login) { return cloneOrThrow(devicePriv.get(login), "devicePriv", login); } - public static byte[] getDevicePublicKey(String login) { return cloneOrThrow(devicePub.get(login), "devicePub", login); } + public static byte[] getDevicePrivatKey(String login) { return cloneOrThrow(clientPriv.get(login), "clientPriv", login); } + public static byte[] getDevicePublicKey(String login) { return cloneOrThrow(clientPub.get(login), "clientPub", login); } public static byte[] getSolanaPrivatKey(String login) { return cloneOrThrow(solanaPriv.get(login), "solanaPriv", login); } public static byte[] getSolanaPublicKey(String login) { return cloneOrThrow(solanaPub.get(login), "solanaPub", login); } @@ -113,7 +113,7 @@ public final class TestConfig { public static byte[] getSessionPublicKey(String login) { return cloneOrThrow(sessionPub.get(login), "sessionPub", login); } // ============ base64 helpers ============ - public static String devicePublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getDevicePublicKey(login)); } + public static String clientPublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getDevicePublicKey(login)); } public static String solanaPublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getSolanaPublicKey(login)); } public static String blockchainPublicKeyB64(String login) { return Base64.getEncoder().encodeToString(getBlockchainPublicKey(login)); } @@ -136,9 +136,9 @@ public final class TestConfig { public static String BLOCKCHAIN2_PUBKEY_B64() { return blockchainPublicKeyB64(LOGIN2()); } public static String BLOCKCHAIN3_PUBKEY_B64() { return blockchainPublicKeyB64(LOGIN3()); } - public static String DEVICE_PUBKEY_B64() { return devicePublicKeyB64(LOGIN()); } - public static String DEVICE2_PUBKEY_B64() { return devicePublicKeyB64(LOGIN2()); } - public static String DEVICE3_PUBKEY_B64() { return devicePublicKeyB64(LOGIN3()); } + public static String DEVICE_PUBKEY_B64() { return clientPublicKeyB64(LOGIN()); } + public static String DEVICE2_PUBKEY_B64() { return clientPublicKeyB64(LOGIN2()); } + public static String DEVICE3_PUBKEY_B64() { return clientPublicKeyB64(LOGIN3()); } // NEW: session pub b64 compat public static String SESSION_PUBKEY_B64() { return sessionPublicKeyB64(LOGIN()); } 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 3952366..07683c2 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 @@ -19,7 +19,7 @@ public final class JsonBuilders { String solanaKeyB64 = TestConfig.solanaPublicKeyB64(login); String blockchainKeyB64 = TestConfig.blockchainPublicKeyB64(login); - String deviceKeyB64 = TestConfig.devicePublicKeyB64(login); + String clientKeyB64 = TestConfig.clientPublicKeyB64(login); return """ { @@ -30,7 +30,7 @@ public final class JsonBuilders { "blockchainName": "%s", "solanaKey": "%s", "blockchainKey": "%s", - "deviceKey": "%s", + "clientKey": "%s", "bchLimit": %d } } @@ -40,7 +40,7 @@ public final class JsonBuilders { blockchainName, solanaKeyB64, blockchainKeyB64, - deviceKeyB64, + clientKeyB64, TestConfig.TEST_BCH_LIMIT ); } @@ -133,7 +133,7 @@ public final class JsonBuilders { } // ---------------- CreateAuthSession (v2) ---------------- - // Подпись CreateAuthSession делается deviceKey над строкой: + // Подпись CreateAuthSession делается clientKey над строкой: // preimage = "AUTH_CREATE_SESSION:" + login + ":" + sessionKey + ":" + storagePwd + ":" + timeMs + ":" + authNonce public static String createAuthSessionV2(String login, String authNonce, String storagePwd, String sessionKey) { @@ -143,9 +143,9 @@ public final class JsonBuilders { 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); - String deviceKey = TestConfig.devicePublicKeyB64(login); - String sigB64 = signAuthCreateSession(login, sessionKey, storagePwd, timeMs, authNonce, devicePriv); + byte[] clientPriv = TestConfig.getDevicePrivatKey(login); + String clientKey = TestConfig.clientPublicKeyB64(login); + String sigB64 = signAuthCreateSession(login, sessionKey, storagePwd, timeMs, authNonce, clientPriv); String requestId = TestIds.next("create"); return """ @@ -158,7 +158,7 @@ public final class JsonBuilders { "sessionKey": "%s", "timeMs": %d, "authNonce": "%s", - "deviceKey": "%s", + "clientKey": "%s", "signatureB64": "%s", "sessionType": %d, "clientPlatform": "%s", @@ -172,7 +172,7 @@ public final class JsonBuilders { sessionKey, timeMs, authNonce, - deviceKey, + clientKey, sigB64, sessionType, clientPlatform == null ? "" : clientPlatform, @@ -431,12 +431,12 @@ public final class JsonBuilders { /** * Подпись CreateAuthSession(v2): * preimage = "AUTH_CREATE_SESSION:" + login + ":" + sessionKey + ":" + storagePwd + ":" + timeMs + ":" + authNonce - * подписываем devicePrivKey. + * подписываем clientPrivKey. */ - public static String signAuthCreateSession(String login, String sessionKey, String storagePwd, long timeMs, String authNonce, byte[] devicePrivKey) { + public static String signAuthCreateSession(String login, String sessionKey, String storagePwd, long timeMs, String authNonce, byte[] clientPrivKey) { String preimageStr = "AUTH_CREATE_SESSION:" + login + ":" + sessionKey + ":" + storagePwd + ":" + timeMs + ":" + authNonce; byte[] preimage = preimageStr.getBytes(StandardCharsets.UTF_8); - byte[] sig = Ed25519Util.sign(preimage, devicePrivKey); + byte[] sig = Ed25519Util.sign(preimage, clientPrivKey); return Base64.getEncoder().encodeToString(sig); } 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 16d7d46..c54642e 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 @@ -229,8 +229,8 @@ public final class JsonParsers { return getPayloadText(json, "blockchainKey"); } - public static String userDeviceKey(String json) { - return getPayloadText(json, "deviceKey"); + public static String userClientKey(String json) { + return getPayloadText(json, "clientKey"); } // ---------------- SearchUsers helpers ---------------- diff --git a/TASKS/02-12.04.26-web-push-direct-message/01-ТЗ-web-push-direct-message.md b/TASKS/02-12.04.26-web-push-direct-message/01-ТЗ-web-push-direct-message.md index 7019aed..5460016 100644 --- a/TASKS/02-12.04.26-web-push-direct-message/01-ТЗ-web-push-direct-message.md +++ b/TASKS/02-12.04.26-web-push-direct-message/01-ТЗ-web-push-direct-message.md @@ -9,7 +9,7 @@ Добавить полностью рабочий сценарий доставки личных сообщений с приоритетом: 1) онлайн-доставка в активную WebSocket-сессию; 2) если не подтверждено — Web Push; -3) поддержать отдельный API отправки без авторизации, где доступ проверяется цифровой подписью Ed25519 по `deviceKey` отправителя. +3) поддержать отдельный API отправки без авторизации, где доступ проверяется цифровой подписью Ed25519 по `clientKey` отправителя. --- @@ -48,7 +48,7 @@ Операция (через WS JSON обертку) условно `SendSignedDirectMessage`: - принимает пакет (base64 binary blob); - парсит и валидирует формат; -- достает `fromLogin`, поднимает `deviceKey` пользователя; +- достает `fromLogin`, поднимает `clientKey` пользователя; - проверяет подпись Ed25519; - проверяет анти-replay (time window + nonce); - отправляет сообщение по правилам маршрутизации; @@ -135,7 +135,7 @@ ### Что уже внедрено в коде - `SendDirectMessage` переведён на signed-binary payload (`blobB64`) без обязательной авторизации WS-сессии. - Внедрён бинарный парсер пакета формата `SHiNE_msg + version(1) + ... + signature64`. -- Проверка подписи Ed25519 делается по `deviceKey` отправителя через `shine-server-crypto` (`Ed25519Util`). +- Проверка подписи Ed25519 делается по `clientKey` отправителя через `shine-server-crypto` (`Ed25519Util`). - Добавлен anti-replay guard `(from_login, time_ms, nonce)` с TTL 15 минут. - Добавлено историческое хранилище `signed_direct_messages_history` с сырым пакетом `raw_packet`. - Логика доставки: сначала WS+ACK, затем fallback на Web Push (по подписке конкретной session). diff --git a/VERSION.properties b/VERSION.properties index faabf5c..85b3f80 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.232 -server.version=1.2.218 +client.version=1.2.233 +server.version=1.2.219 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 22b61b1..e3a6eaf 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -57,12 +57,12 @@ import * as serverSettingsView from './pages/server-settings-view.js?v=202606161 import * as toolsSettingsView from './pages/tools-settings-view.js'; import * as deviceView from './pages/device-view.js?v=202606131435'; import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055'; -import * as devicePairingView from './pages/device-pairing-view.js?v=202606180940'; +import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940'; import * as trustedDeviceLoginSettingsView from './pages/trusted-device-login-settings-view.js?v=202606180930'; import * as deviceQrView from './pages/device-qr-view.js'; import * as deviceCameraView from './pages/device-camera-view.js'; import * as showKeysView from './pages/show-keys-view.js'; -import * as deviceSessionView from './pages/device-session-view.js?v=202606131435'; +import * as clientSessionView from './pages/device-session-view.js?v=202606131435'; import * as languageView from './pages/language-view.js'; import * as appLogView from './pages/app-log-view.js'; import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js'; @@ -103,12 +103,12 @@ const routes = { 'tools-settings-view': toolsSettingsView, 'device-view': deviceView, 'connect-device-view': connectDeviceView, - 'device-pairing-view': devicePairingView, + 'device-pairing-view': clientPairingView, 'trusted-device-login-settings-view': trustedDeviceLoginSettingsView, 'device-qr-view': deviceQrView, 'device-camera-view': deviceCameraView, 'show-keys-view': showKeysView, - 'device-session-view': deviceSessionView, + 'device-session-view': clientSessionView, 'language-view': languageView, 'app-log-view': appLogView, 'pwa-diagnostics-view': pwaDiagnosticsView, diff --git a/shine-UI/js/components/avatar-wizard.js b/shine-UI/js/components/avatar-wizard.js index 2e9ef06..ae2d38a 100644 --- a/shine-UI/js/components/avatar-wizard.js +++ b/shine-UI/js/components/avatar-wizard.js @@ -1,5 +1,5 @@ import { authService } from '../state.js'; -import { getArweaveBalance, getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js'; +import { getArweaveBalance, getArweaveWalletFromStoredClientKey } from '../services/arweave-wallet-service.js'; import { buildArweaveDataUrl, getArweaveUploadPrice, @@ -596,7 +596,7 @@ export function openAvatarWizard({ const errorEl = root.querySelector('[data-error="true"]'); root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice); try { - walletCtx = await getArweaveWalletFromStoredDeviceKey({ + walletCtx = await getArweaveWalletFromStoredClientKey({ login: cleanLogin, storagePwd: cleanStoragePwd, onStatus: (message) => { diff --git a/shine-UI/js/mock-data.js b/shine-UI/js/mock-data.js index 250081c..60c7b3b 100644 --- a/shine-UI/js/mock-data.js +++ b/shine-UI/js/mock-data.js @@ -15,7 +15,7 @@ export const wallet = { updatedAt: 'сегодня, 14:42', }; -export const deviceSessions = [ +export const clientSessions = [ { sessionId: 'sess_7c5e5c4b', clientInfoFromClient: 'Android 15; Pixel 9', diff --git a/shine-UI/js/pages/connect-device-view.js b/shine-UI/js/pages/connect-device-view.js index b135721..b869c1b 100644 --- a/shine-UI/js/pages/connect-device-view.js +++ b/shine-UI/js/pages/connect-device-view.js @@ -21,7 +21,7 @@ export function render({ navigate }) {

Выберите, какие ключи передать на подключаемое устройство

- +

Проверяем ключи на этом устройстве...

@@ -68,7 +68,7 @@ export function render({ navigate }) {

если ключа нет — он недоступен

blockchain key — можно передать или нет

root key — только если существует

-

device key передаётся всегда

+

client key передаётся всегда

подключение происходит напрямую через QR

сервер не используется

текущая логика: устройство 1 показывает QR, устройство 2 сканирует

@@ -100,7 +100,7 @@ export function render({ navigate }) { const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory); const hasRoot = Boolean(savedKeys.rootKey); const hasBlockchain = Boolean(savedKeys.blockchainKey); - const hasDevice = Boolean(savedKeys.deviceKey); + const hasDevice = Boolean(savedKeys.clientKey); rootToggle.disabled = !hasRoot; blockchainToggle.disabled = !hasBlockchain; diff --git a/shine-UI/js/pages/developer-settings-view.js b/shine-UI/js/pages/developer-settings-view.js index d7b6d7b..ab7b7a9 100644 --- a/shine-UI/js/pages/developer-settings-view.js +++ b/shine-UI/js/pages/developer-settings-view.js @@ -11,7 +11,7 @@ import { promptPwaInstall, } from '../services/pwa-install-service.js'; import { initPwaPush } from '../services/pwa-push-service.js'; -import { getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js'; +import { getArweaveWalletFromStoredClientKey } from '../services/arweave-wallet-service.js'; import { prepareAvatarImageFile, uploadArweaveFile, @@ -143,7 +143,7 @@ function openDeveloperAvatarUploadModal({ walletLogin, storagePwd, gateway } = { + `(${optimized.width}x${optimized.height}, ${optimized.contentType})`, ); - walletCtx = await getArweaveWalletFromStoredDeviceKey({ + walletCtx = await getArweaveWalletFromStoredClientKey({ login: walletLogin, storagePwd, onStatus: (message) => setMeta(message), diff --git a/shine-UI/js/pages/device-pairing-view.js b/shine-UI/js/pages/device-pairing-view.js index 17e2e18..5496d7f 100644 --- a/shine-UI/js/pages/device-pairing-view.js +++ b/shine-UI/js/pages/device-pairing-view.js @@ -38,12 +38,12 @@ function pairingSessionKindLabel(sessionType) { function buildTransferKeys(savedKeys, { withExtras = false }) { const keys = { - deviceKey: String(savedKeys?.deviceKey || '').trim(), + clientKey: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(), blockchainKey: '', rootKey: '', }; - if (!keys.deviceKey) { - throw new Error('На этом устройстве нет сохранённого device key для передачи.'); + if (!keys.clientKey) { + throw new Error('На этом устройстве нет сохранённого client key для передачи.'); } if (withExtras) { if (state.deviceConnect.blockchain && savedKeys?.blockchainKey) { @@ -432,12 +432,12 @@ export function render({ navigate }) { const loadSavedKeys = async () => { savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory); const available = []; - if (savedKeys?.deviceKey) available.push('device'); + if (savedKeys?.clientKey || savedKeys?.clientKey) available.push('client'); if (savedKeys?.blockchainKey && state.deviceConnect.blockchain) available.push('blockchain'); if (savedKeys?.rootKey && state.deviceConnect.root) available.push('root'); keySummaryEl.textContent = available.length ? `При расширенном подключении будут переданы: ${available.join(', ')}.` - : 'На этом устройстве доступен только device key.'; + : 'На этом устройстве доступен только client key.'; }; const reloadRequests = async ({ silent = false } = {}) => { @@ -464,9 +464,9 @@ export function render({ navigate }) { const withExtras = mode === 'with-extras'; let payload; if (!withExtras && Number(request?.requesterSessionType || 0) === SESSION_TYPE_WALLET) { - const delegatedSession = await authService.createDelegatedSessionWithDeviceKey({ + const delegatedSession = await authService.createDelegatedSessionWithClientKey({ login: state.session.login, - devicePrivPkcs8: String(savedKeys?.deviceKey || '').trim(), + clientPrivPkcs8: String(savedKeys?.clientKey || savedKeys?.clientKey || '').trim(), sessionKey: String(request?.requesterSessionKey || '').trim(), sessionType: Number(request?.requesterSessionType || SESSION_TYPE_WALLET) || SESSION_TYPE_WALLET, clientPlatform: String(request?.requesterClientPlatform || '').trim() || 'Wallet plugin', diff --git a/shine-UI/js/pages/device-qr-view.js b/shine-UI/js/pages/device-qr-view.js index 4c09e69..44d5365 100644 --- a/shine-UI/js/pages/device-qr-view.js +++ b/shine-UI/js/pages/device-qr-view.js @@ -42,11 +42,11 @@ export function render({ navigate }) { } const savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory); const keys = { - deviceKey: savedKeys.deviceKey || '', + clientKey: savedKeys.clientKey || '', blockchainKey: state.deviceConnect.blockchain ? (savedKeys.blockchainKey || '') : '', rootKey: state.deviceConnect.root ? (savedKeys.rootKey || '') : '', }; - if (!keys.deviceKey) throw new Error('На этом устройстве нет device key'); + if (!keys.clientKey) throw new Error('На этом устройстве нет client key'); const qrText = makeKeyTransferText({ login: state.session.login, keys }); qrEl.innerHTML = renderQrSvg(qrText); diff --git a/shine-UI/js/pages/key-storage-view.js b/shine-UI/js/pages/key-storage-view.js index 02502ce..17b41fa 100644 --- a/shine-UI/js/pages/key-storage-view.js +++ b/shine-UI/js/pages/key-storage-view.js @@ -43,7 +43,7 @@ export function render({ navigate }) {
- +
`; @@ -63,7 +63,7 @@ export function render({ navigate }) { const deviceInput = card.children[2].querySelector('.input'); deviceInput.addEventListener('input', () => { - state.keyStorage.deviceKey = deviceInput.value; + state.keyStorage.clientKey = deviceInput.value; }); const actions = document.createElement('div'); diff --git a/shine-UI/js/pages/login-camera-view.js b/shine-UI/js/pages/login-camera-view.js index 99f40df..53ecb9f 100644 --- a/shine-UI/js/pages/login-camera-view.js +++ b/shine-UI/js/pages/login-camera-view.js @@ -141,8 +141,8 @@ export function render({ navigate }) { const parseTransferText = (text) => { try { const transfer = parseKeyTransferText(text); - if (!transfer.keys.deviceKey) { - throw new Error('В QR-коде нет device key для входа'); + if (!transfer.keys.clientKey) { + throw new Error('В QR-коде нет client key для входа'); } showTransfer(transfer); } catch (error) { diff --git a/shine-UI/js/pages/register-view.js b/shine-UI/js/pages/register-view.js index e86cf2f..61e62c1 100644 --- a/shine-UI/js/pages/register-view.js +++ b/shine-UI/js/pages/register-view.js @@ -379,7 +379,7 @@ export function render({ navigate }) { const details2 = document.createElement('p'); details2.className = 'meta-muted'; - details2.textContent = 'Из этого секрета строятся root key, blockchain key и device key. Это может занять некоторое время.'; + details2.textContent = 'Из этого секрета строятся recovery key, root key, blockchain key и client key. Это может занять некоторое время.'; const details3 = document.createElement('p'); details3.className = 'meta-muted'; diff --git a/shine-UI/js/pages/registration-draft-keys-view.js b/shine-UI/js/pages/registration-draft-keys-view.js index 1400049..547e6c8 100644 --- a/shine-UI/js/pages/registration-draft-keys-view.js +++ b/shine-UI/js/pages/registration-draft-keys-view.js @@ -1,7 +1,7 @@ import { renderHeader } from '../components/header.js'; import { state } from '../state.js'; import { base64ToBytes, bytesToBase58 } from '../services/crypto-utils.js'; -import { extractSeed32FromPkcs8B64 } from '../services/device-key-utils.js'; +import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js'; export const pageMeta = { id: 'registration-draft-keys-view', title: 'Сгенерированные ключи', showAppChrome: false }; @@ -109,6 +109,20 @@ export function render({ navigate }) { } card.append(makeSecretField({ label: 'Главный секрет (master secret, base58, 32 байта)', value: secretB58 })); + // Recovery key + const recoverySep = document.createElement('p'); + recoverySep.className = 'field-label'; + recoverySep.textContent = 'Recovery key'; + card.append(recoverySep); + card.append(makePublicField({ + label: 'Recovery — публичный (base58)', + value: bytesToBase58(base64ToBytes(keyBundle.recoveryPair.publicKeyB64)), + })); + card.append(makeSecretField({ + label: 'Recovery — приватный (seed base58, 32 байта)', + value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.recoveryPair.privatePkcs8B64)), + })); + // Root key const rootSep = document.createElement('p'); rootSep.className = 'field-label'; @@ -137,18 +151,18 @@ export function render({ navigate }) { value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.blockchainPair.privatePkcs8B64)), })); - // Device key + // Client key const devSep = document.createElement('p'); devSep.className = 'field-label'; - devSep.textContent = 'Device key (= Solana wallet)'; + devSep.textContent = 'Client key (= Solana wallet)'; card.append(devSep); card.append(makePublicField({ - label: 'Device — публичный (base58)', - value: bytesToBase58(base64ToBytes(keyBundle.devicePair.publicKeyB64)), + label: 'Client — публичный (base58)', + value: bytesToBase58(base64ToBytes(keyBundle.clientPair.publicKeyB64)), })); card.append(makeSecretField({ - label: 'Device — приватный (seed base58, 32 байта)', - value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64)), + label: 'Client — приватный (seed base58, 32 байта)', + value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.clientPair.privatePkcs8B64)), })); } diff --git a/shine-UI/js/pages/registration-faq-view.js b/shine-UI/js/pages/registration-faq-view.js index e888962..078d2e1 100644 --- a/shine-UI/js/pages/registration-faq-view.js +++ b/shine-UI/js/pages/registration-faq-view.js @@ -30,7 +30,7 @@ export const REGISTRATION_FAQ_TOPICS = [ title: 'Как генерируются ключи и что делает пароль?', paragraphs: [ 'Из вашего логина и пароля с помощью Argon2id вычисляется специальный секрет.', - 'Уже из этого секрета детерминированно строятся три основных ключа: root key, blockchain key и device key.', + 'Уже из этого секрета детерминированно строятся четыре основных ключа: recovery key, root key, blockchain key и client key.', 'Это значит, что логин и пароль не просто проверяются на сервере, а реально участвуют в создании ваших ключей. У разных логинов даже с одинаковым паролем будут разные ключи.', ], }, @@ -41,7 +41,7 @@ export const REGISTRATION_FAQ_TOPICS = [ paragraphs: [ 'Root key нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.', 'Blockchain key нужен для подписания действий и записей в блокчейне SHiNE.', - 'Device key нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.', + 'Client key нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.', 'Если не хочется в это вникать, обычно можно просто сохранить все ключи на своём устройстве. Для большинства обычных сценариев на iPhone, Android и Linux это вполне практично. Для больших сумм или повышенного риска лучше отдельное внешнее устройство.', ], }, diff --git a/shine-UI/js/pages/registration-payment-view.js b/shine-UI/js/pages/registration-payment-view.js index 013896b..cd4afc5 100644 --- a/shine-UI/js/pages/registration-payment-view.js +++ b/shine-UI/js/pages/registration-payment-view.js @@ -113,7 +113,7 @@ export function render({ navigate }) { const deriveUserWalletAddress = async () => { const keyBundle = state.registrationDraft.preGeneratedKeyBundle; if (!keyBundle) throw new Error('Ключи ещё не сгенерированы. Вернитесь на предыдущий шаг.'); - const { publicKeyB64 } = keyBundle.devicePair; + const { publicKeyB64 } = keyBundle.clientPair; const raw = atob(publicKeyB64); const bytes = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); @@ -219,7 +219,7 @@ export function render({ navigate }) { card.innerHTML = `

Для регистрации в тестовой Solana нужно минимум 0,01 SOL на вашем кошельке.

- +
Баланс (Solana)
@@ -242,7 +242,7 @@ export function render({ navigate }) { await refreshBalance({ addressOverride: walletAddress }); } catch (error) { status.className = 'status-line is-unavailable'; - status.textContent = `Не удалось подготовить wallet.key: ${error?.message || 'unknown'}`; + status.textContent = `Не удалось подготовить client.key: ${error?.message || 'unknown'}`; status.style.display = ''; } })(); diff --git a/shine-UI/js/pages/show-keys-view.js b/shine-UI/js/pages/show-keys-view.js index 1aa1b8f..7cc7c96 100644 --- a/shine-UI/js/pages/show-keys-view.js +++ b/shine-UI/js/pages/show-keys-view.js @@ -1,7 +1,7 @@ import { renderHeader } from '../components/header.js'; import { state } from '../state.js'; import { bytesToBase58 } from '../services/crypto-utils.js'; -import { extractSeed32FromPkcs8B64 } from '../services/device-key-utils.js'; +import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js'; import { loadEncryptedUserSecrets } from '../services/key-vault.js'; export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' }; @@ -68,7 +68,7 @@ export function render({ navigate }) { card.append( renderField('root', 'root key (base58)'), renderField('blockchain', 'blockchain.key (base58)'), - renderField('device', 'device key (base58)'), + renderField('device', 'client key (base58)'), ); const setMissingState = (id) => { @@ -133,11 +133,11 @@ export function render({ navigate }) { const rootSeed32 = savedKeys.rootKey ? extractSeed32FromPkcs8B64(savedKeys.rootKey) : null; const blockchainSeed32 = savedKeys.blockchainKey ? extractSeed32FromPkcs8B64(savedKeys.blockchainKey) : null; - const deviceSeed32 = savedKeys.deviceKey ? extractSeed32FromPkcs8B64(savedKeys.deviceKey) : null; + const clientSeed32 = savedKeys.clientKey ? extractSeed32FromPkcs8B64(savedKeys.clientKey) : null; keys.root = rootSeed32 ? bytesToBase58(rootSeed32) : ''; keys.blockchain = blockchainSeed32 ? bytesToBase58(blockchainSeed32) : ''; - keys.device = deviceSeed32 ? bytesToBase58(deviceSeed32) : ''; + keys.device = clientSeed32 ? bytesToBase58(clientSeed32) : ''; if (keys.root || keys.blockchain || keys.device) { status.textContent = 'Показаны только ключи, сохранённые на этом устройстве.'; diff --git a/shine-UI/js/pages/topup-view.js b/shine-UI/js/pages/topup-view.js index bfc87fd..36038f1 100644 --- a/shine-UI/js/pages/topup-view.js +++ b/shine-UI/js/pages/topup-view.js @@ -12,12 +12,12 @@ export const pageMeta = { id: 'topup-view', title: 'Пополнение сче // Канонический Solana-адрес пополнения = публичный device-ключ из сгенерированного набора ключей. // Тот же путь, что в registration-payment-view (deriveUserWalletAddress); не выводим адрес // напрямую из пароля, иначе он расходится с device-ключом регистрации. -async function deviceWalletAddressFromBundle() { +async function clientWalletAddressFromBundle() { const keyBundle = state.registrationDraft.preGeneratedKeyBundle; - if (!keyBundle || !keyBundle.devicePair) { + if (!keyBundle || !keyBundle.clientPair) { throw new Error('Ключи ещё не сгенерированы. Вернитесь на экран регистрации.'); } - const raw = atob(keyBundle.devicePair.publicKeyB64); + const raw = atob(keyBundle.clientPair.publicKeyB64); const bytes = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i); const { PublicKey } = await import('https://esm.sh/@solana/web3.js@1.98.4'); @@ -69,7 +69,7 @@ export function render({ navigate }) {
Открыть сайт пополнения
-
Кошелёк для пополнения (device key = Solana wallet)
+
Кошелёк для пополнения (client key = Solana wallet)
`; card.children[3].append(walletRow); @@ -117,7 +117,7 @@ export function render({ navigate }) { (async () => { try { if (!walletValue.value) { - const address = await deviceWalletAddressFromBundle(); + const address = await clientWalletAddressFromBundle(); state.registrationPayment.walletAddress = address; walletValue.value = address; } diff --git a/shine-UI/js/pages/wallet-view.js b/shine-UI/js/pages/wallet-view.js index ca6e529..bfe647c 100644 --- a/shine-UI/js/pages/wallet-view.js +++ b/shine-UI/js/pages/wallet-view.js @@ -6,14 +6,14 @@ import { formatSol, getBalanceSol, getTopupSiteUrl, - getWalletFromStoredDeviceKey, + getWalletFromStoredClientKey, transferSol, } from '../services/solana-wallet-service.js'; import { formatAr, getArweaveBalance, getArweaveTopupSiteUrl, - getArweaveWalletFromStoredDeviceKey, + getArweaveWalletFromStoredClientKey, transferAr, } from '../services/arweave-wallet-service.js'; import { loadEncryptedUserSecrets } from '../services/key-vault.js'; @@ -262,10 +262,10 @@ export function render({ navigate }) { } let rootKey = String(saved?.rootKey || '').trim(); let blockchainKey = String(saved?.blockchainKey || '').trim(); - const deviceKey = String(saved?.deviceKey || '').trim(); - if (!deviceKey) throw new Error('На устройстве нет device.key. Выполните вход заново.'); + const clientKey = String(saved?.clientKey || '').trim(); + if (!clientKey) throw new Error('На устройстве нет client.key. Выполните вход заново.'); if (rootKey && blockchainKey) { - return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, devicePrivatePkcs8B64: deviceKey }; + return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey }; } const password = window.prompt( @@ -284,7 +284,7 @@ export function render({ navigate }) { if (shouldSave) { await authService.persistSelectedKeys(login, storagePwd, keyBundle, { saveRoot: true, saveBlockchain: true }); } - return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, devicePrivatePkcs8B64: deviceKey }; + return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey }; }; const setButtonsDisabled = (disabled) => { @@ -341,7 +341,7 @@ export function render({ navigate }) { solanaEndpoint: state.entrySettings.solanaServer, rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64, blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64, - devicePrivatePkcs8B64: signing.devicePrivatePkcs8B64, + clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64, additionalLimitBytes: 0n, nextUsedBytes: BigInt(Math.max(0, serverState.sizeBytes)), nextLastBlockNumber: serverState.lastNumber, @@ -388,7 +388,7 @@ export function render({ navigate }) { solanaEndpoint: state.entrySettings.solanaServer, rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64, blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64, - devicePrivatePkcs8B64: signing.devicePrivatePkcs8B64, + clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64, additionalLimitBytes: addBytes, }); if (modeToken !== activeModeToken) return; @@ -443,7 +443,7 @@ export function render({ navigate }) { addressCard.className = 'card'; addressCard.style.padding = '10px'; addressCard.innerHTML = ` -

Публичный адрес (wallet.key = device.key)

+

Публичный адрес (client.key)

`; const addressEl = addressCard.querySelector('#wallet-address-value'); @@ -705,7 +705,7 @@ export function render({ navigate }) { sendBtn.addEventListener('click', async () => { if (!walletCtx?.keypair) { - setStatus('Перевод недоступен: wallet.key не загружен.'); + setStatus('Перевод недоступен: client.key не загружен.'); return; } const toAddress = window.prompt('Введите адрес получателя (Solana):', ''); @@ -741,17 +741,17 @@ export function render({ navigate }) { }); content.append(backBtn, card, actions, generatedCard); - setStatus('Инициализация wallet.key...'); + setStatus('Инициализация client.key...'); try { - walletCtx = await getWalletFromStoredDeviceKey(sessionArgsOrThrow()); + walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow()); if (modeToken !== activeModeToken) return; walletAddress = walletCtx.address; addressEl.textContent = walletAddress; await refreshBalance(); } catch (error) { if (modeToken !== activeModeToken) return; - addressEl.textContent = 'wallet.key недоступен'; + addressEl.textContent = 'client.key недоступен'; setStatus(`Не удалось инициализировать кошелёк: ${error?.message || 'unknown'}`); } } @@ -797,7 +797,7 @@ export function render({ navigate }) { helpCard.innerHTML = ` Как получен этот адрес?

- SHiNE берёт ваш локальный device.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк. + SHiNE берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк. Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства.

`; @@ -899,14 +899,14 @@ export function render({ navigate }) { try { let wasFirstTimeGeneration = false; - arweaveWalletCtx = await getArweaveWalletFromStoredDeviceKey({ + arweaveWalletCtx = await getArweaveWalletFromStoredClientKey({ ...sessionArgsOrThrow(), onStatus: (message) => { const text = String(message || '').trim(); if (!text) return; if (text.includes('впервые получаем Arweave-кошелёк')) { wasFirstTimeGeneration = true; - setStatus('Подождите — ваш Arweave-ключ вычисляется из device key. Это происходит только один раз, потом будет мгновенно.'); + setStatus('Подождите — ваш Arweave-ключ вычисляется из client key. Это происходит только один раз, потом будет мгновенно.'); return; } setStatus(text); @@ -919,7 +919,7 @@ export function render({ navigate }) { await refreshBalance(); } catch (error) { if (modeToken !== activeModeToken) return; - addressEl.textContent = 'wallet.key недоступен'; + addressEl.textContent = 'client.key недоступен'; clearArweaveSecretsInMemory(); setStatus(`Не удалось инициализировать Arweave-кошелёк: ${error?.message || 'unknown'}`); } diff --git a/shine-UI/js/services/arweave-wallet-service.js b/shine-UI/js/services/arweave-wallet-service.js index 2ead335..9387b04 100644 --- a/shine-UI/js/services/arweave-wallet-service.js +++ b/shine-UI/js/services/arweave-wallet-service.js @@ -1,6 +1,6 @@ import { loadEncryptedUserSecrets, updateEncryptedUserSecrets } from './key-vault.js'; -import { extractDeviceKey32FromStoredValue } from './device-key-utils.js'; -import { deriveArweaveWalletFromDeviceKey32 } from './sawd-v1.js'; +import { extractClientKey32FromStoredValue } from './client-key-utils.js'; +import { deriveArweaveWalletFromClientKey32 } from './sawd-v1.js'; const DEFAULT_ARWEAVE_GATEWAY = 'https://arweave.net'; const AR_TOPUP_URL = 'https://changenow.io/exchange?from=usd&to=ar&amount=10&fiatMode=true'; @@ -102,11 +102,11 @@ function safeStatus(onStatus, text) { } } -export async function getArweaveWalletFromStoredDeviceKey({ login, storagePwd, onStatus } = {}) { +export async function getArweaveWalletFromStoredClientKey({ login, storagePwd, onStatus } = {}) { const cleanLogin = String(login || '').trim(); const cleanPwd = String(storagePwd || '').trim(); if (!cleanLogin || !cleanPwd) { - throw new Error('Нет активной сессии для доступа к wallet.key'); + throw new Error('Нет активной сессии для доступа к client.key'); } const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd); @@ -116,19 +116,19 @@ export async function getArweaveWalletFromStoredDeviceKey({ login, storagePwd, o return cached; } - safeStatus(onStatus, 'Сейчас мы впервые получаем Arweave-кошелёк из вашего device key. Это может занять немного времени.'); + safeStatus(onStatus, 'Сейчас мы впервые получаем Arweave-кошелёк из вашего client key. Это может занять немного времени.'); - const storedDeviceKey = String(secrets?.deviceKey || '').trim(); - if (!storedDeviceKey) { - throw new Error('На устройстве не найден device.key (wallet.key)'); + const storedClientKey = String(secrets?.clientKey || '').trim(); + if (!storedClientKey) { + throw new Error('На устройстве не найден client.key'); } - const deviceKey32 = extractDeviceKey32FromStoredValue(storedDeviceKey); + const clientKey32 = extractClientKey32FromStoredValue(storedClientKey); let wallet; try { - wallet = await deriveArweaveWalletFromDeviceKey32(deviceKey32); + wallet = await deriveArweaveWalletFromClientKey32(clientKey32); } finally { - deviceKey32.fill(0); + clientKey32.fill(0); } const cachedWallet = { diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 8b8d55d..c314e2a 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -809,22 +809,27 @@ export class AuthService { }); if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED'); - if (onProgress) onProgress({ percent: 94, stage: 'derive', message: 'Вычисление root key...' }); + if (onProgress) onProgress({ percent: 93, stage: 'derive', message: 'Вычисление recovery key...' }); + const recoveryPair = await deriveEd25519FromMasterSecret(masterSecret, 'recovery.key'); + if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED'); + + if (onProgress) onProgress({ percent: 95, stage: 'derive', message: 'Вычисление root key...' }); const rootPair = await deriveEd25519FromMasterSecret(masterSecret, 'root.key'); if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED'); - if (onProgress) onProgress({ percent: 96, stage: 'derive', message: 'Вычисление blockchain key...' }); - const blockchainPair = await deriveEd25519FromMasterSecret(masterSecret, 'bch.key'); + if (onProgress) onProgress({ percent: 97, stage: 'derive', message: 'Вычисление blockchain key...' }); + const blockchainPair = await deriveEd25519FromMasterSecret(masterSecret, 'blockchain.key'); if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED'); - if (onProgress) onProgress({ percent: 98, stage: 'derive', message: 'Вычисление device key...' }); - const devicePair = await deriveEd25519FromMasterSecret(masterSecret, 'dev.key'); - const result = { - masterSecretB64: bytesToBase64(masterSecret), - rootPair, - blockchainPair, - devicePair, - }; + if (onProgress) onProgress({ percent: 99, stage: 'derive', message: 'Вычисление client key...' }); + const clientPair = await deriveEd25519FromMasterSecret(masterSecret, 'client.key'); + const result = { + masterSecretB64: bytesToBase64(masterSecret), + recoveryPair, + rootPair, + blockchainPair, + clientPair, + }; this.passwordKeyBundleCache.set(cacheKey, result); if (onProgress) onProgress({ percent: 100, stage: 'done', message: 'Ключи сгенерированы.' }); return result; @@ -839,6 +844,8 @@ export class AuthService { async createAuthSession(login, keyBundle) { const cleanLogin = (login || '').trim(); if (!cleanLogin) throw new Error('Введите логин'); + const clientPair = keyBundle?.clientPair; + if (!clientPair) throw new Error('createAuthSession: не передан clientPair'); const sessionPair = await generateEd25519Pair(); const sessionKeyPub = await exportEd25519PublicKeyB64(sessionPair.publicKey); @@ -853,7 +860,7 @@ export class AuthService { const timeMs = Date.now(); const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${sessionKey}:${storagePwd}:${timeMs}:${authNonce}`; - const signatureB64 = await signBase64(keyBundle.devicePair.privateKey, preimage); + const signatureB64 = await signBase64(clientPair.privateKey, preimage); const createResp = await this.ws.request('CreateAuthSession', { login: cleanLogin, @@ -861,7 +868,7 @@ export class AuthService { sessionKey, timeMs, authNonce, - deviceKey: keyBundle.devicePair.publicKeyB64, + clientKey: clientPair.publicKeyB64, signatureB64, sessionType: SESSION_TYPE_CLIENT, clientPlatform: makeClientPlatform(), @@ -892,13 +899,14 @@ export class AuthService { if (!isFree) throw new Error('Этот логин уже занят'); const keyBundle = await this.derivePasswordKeyBundle(cleanLogin, password); + const clientPair = keyBundle?.clientPair; const addResp = await this.ws.request('AddUser', { login: cleanLogin, blockchainName: `${cleanLogin}-${BCH_SUFFIX}`, - solanaKey: keyBundle.devicePair.publicKeyB64, + solanaKey: clientPair.publicKeyB64, blockchainKey: keyBundle.blockchainPair.publicKeyB64, - deviceKey: keyBundle.devicePair.publicKeyB64, + clientKey: clientPair.publicKeyB64, bchLimit: 1000000, }); if (addResp.status !== 200) throw opError('AddUser', addResp); @@ -910,13 +918,14 @@ export class AuthService { async registerUserWithKeyBundle(login, keyBundle) { const cleanLogin = (login || '').trim(); if (!cleanLogin) throw new Error('Введите логин'); + const clientPair = keyBundle?.clientPair; const addResp = await this.ws.request('AddUser', { login: cleanLogin, blockchainName: `${cleanLogin}-${BCH_SUFFIX}`, - solanaKey: keyBundle.devicePair.publicKeyB64, + solanaKey: clientPair.publicKeyB64, blockchainKey: keyBundle.blockchainPair.publicKeyB64, - deviceKey: keyBundle.devicePair.publicKeyB64, + clientKey: clientPair.publicKeyB64, bchLimit: 1000000, }); if (addResp.status !== 200) throw opError('AddUser', addResp); @@ -937,13 +946,13 @@ export class AuthService { async createSessionFromImportedSecrets(login, secrets) { const cleanLogin = (login || '').trim(); if (!cleanLogin) throw new Error('В QR-коде нет логина'); - const deviceKey = String(secrets?.deviceKey || '').trim(); - if (!deviceKey) throw new Error('В QR-коде нет device key для входа'); + const clientKey = String(secrets?.clientKey || secrets?.clientKey || '').trim(); + if (!clientKey) throw new Error('В QR-коде нет client key для входа'); - const privateKey = await importPkcs8Ed25519(deviceKey); - const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(deviceKey); + const privateKey = await importPkcs8Ed25519(clientKey); + const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(clientKey); const session = await this.createAuthSession(cleanLogin, { - devicePair: { + clientPair: { privateKey, publicKeyB64, }, @@ -951,9 +960,9 @@ export class AuthService { return session; } - async createDelegatedSessionWithDeviceKey({ + async createDelegatedSessionWithClientKey({ login, - devicePrivPkcs8, + clientPrivPkcs8, sessionKey, sessionType = SESSION_TYPE_WALLET, clientPlatform = 'Delegated session', @@ -961,13 +970,13 @@ export class AuthService { }) { const cleanLogin = String(login || '').trim(); const cleanSessionKey = String(sessionKey || '').trim(); - const cleanDevicePriv = String(devicePrivPkcs8 || '').trim(); - if (!cleanLogin) throw new Error('createDelegatedSessionWithDeviceKey: пустой login'); - if (!cleanSessionKey) throw new Error('createDelegatedSessionWithDeviceKey: пустой sessionKey'); - if (!cleanDevicePriv) throw new Error('createDelegatedSessionWithDeviceKey: пустой device private key'); + const cleanClientPriv = String(clientPrivPkcs8 || '').trim(); + if (!cleanLogin) throw new Error('createDelegatedSessionWithClientKey: пустой login'); + if (!cleanSessionKey) throw new Error('createDelegatedSessionWithClientKey: пустой sessionKey'); + if (!cleanClientPriv) throw new Error('createDelegatedSessionWithClientKey: пустой client private key'); - const devicePrivateKey = await importPkcs8Ed25519(cleanDevicePriv); - const devicePublicKeyB64 = await publicKeyB64FromPkcs8Ed25519(cleanDevicePriv); + const clientPrivateKey = await importPkcs8Ed25519(cleanClientPriv); + const clientPublicKeyB64 = await publicKeyB64FromPkcs8Ed25519(cleanClientPriv); const storagePwd = randomBase64(32); const tempAuth = new AuthService(this.serverUrl); @@ -980,7 +989,7 @@ export class AuthService { const timeMs = Date.now(); const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${cleanSessionKey}:${storagePwd}:${timeMs}:${authNonce}`; - const signatureB64 = await signBase64(devicePrivateKey, preimage); + const signatureB64 = await signBase64(clientPrivateKey, preimage); const createResp = await tempAuth.ws.request('CreateAuthSession', { login: cleanLogin, @@ -988,7 +997,7 @@ export class AuthService { sessionKey: cleanSessionKey, timeMs, authNonce, - deviceKey: devicePublicKeyB64, + clientKey: clientPublicKeyB64, signatureB64, sessionType: Number(sessionType) || SESSION_TYPE_WALLET, clientPlatform: String(clientPlatform || '').trim() || 'Delegated session', @@ -1025,7 +1034,7 @@ export class AuthService { const secrets = { ...currentSecrets, - deviceKey: keyBundle.devicePair.privatePkcs8B64, + clientKey: keyBundle.clientPair.privatePkcs8B64, }; if (saveOptions.saveRoot) secrets.rootKey = keyBundle.rootPair.privatePkcs8B64; if (saveOptions.saveBlockchain) secrets.blockchainKey = keyBundle.blockchainPair.privatePkcs8B64; @@ -1898,9 +1907,9 @@ export class AuthService { if (!storagePwd) throw new Error('Не передан storagePwd для подписи'); const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd); - const devicePriv = secrets?.deviceKey; - if (!devicePriv) throw new Error('Не найден приватный deviceKey'); - const privateKey = await importPkcs8Ed25519(devicePriv); + const clientPriv = secrets?.clientKey || secrets?.clientKey; + if (!clientPriv) throw new Error('Не найден приватный clientKey'); + const privateKey = await importPkcs8Ed25519(clientPriv); const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin'); const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin'); @@ -1941,9 +1950,9 @@ export class AuthService { } const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd); - const devicePriv = secrets?.deviceKey; - if (!devicePriv) throw new Error('Не найден приватный deviceKey'); - const privateKey = await importPkcs8Ed25519(devicePriv); + const clientPriv = secrets?.clientKey || secrets?.clientKey; + if (!clientPriv) throw new Error('Не найден приватный clientKey'); + const privateKey = await importPkcs8Ed25519(clientPriv); const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin'); const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin'); diff --git a/shine-UI/js/services/device-key-utils.js b/shine-UI/js/services/client-key-utils.js similarity index 70% rename from shine-UI/js/services/device-key-utils.js rename to shine-UI/js/services/client-key-utils.js index c104141..d0a3409 100644 --- a/shine-UI/js/services/device-key-utils.js +++ b/shine-UI/js/services/client-key-utils.js @@ -18,14 +18,14 @@ function parseKeypairJson64(raw) { if (!isByteArrayLike(parsed)) return null; const asArray = Array.from(parsed); if (asArray.length < 32) { - throw new Error('Некорректный JSON ключ device.key: ожидалось минимум 32 байта'); + throw new Error('Некорректный JSON ключ client.key: ожидалось минимум 32 байта'); } const out = new Uint8Array(asArray.length); for (let i = 0; i < asArray.length; i += 1) { const n = Number(asArray[i]); if (!Number.isInteger(n) || n < 0 || n > 255) { - throw new Error('Некорректный JSON ключ device.key: найдены не-байтовые значения'); + throw new Error('Некорректный JSON ключ client.key: найдены не-байтовые значения'); } out[i] = n; } @@ -34,13 +34,13 @@ function parseKeypairJson64(raw) { export function extractSeed32FromPkcs8B64(pkcs8B64) { const bytes = base64ToBytes(String(pkcs8B64 || '').trim()); - if (bytes.length < 32) throw new Error('Некорректный PKCS8 ключ device.key'); + if (bytes.length < 32) throw new Error('Некорректный PKCS8 ключ client.key'); return bytes.slice(bytes.length - 32); } -export function extractDeviceKey32FromStoredValue(storedDeviceKey) { - const raw = String(storedDeviceKey || '').trim(); - if (!raw) throw new Error('Пустой device.key'); +export function extractClientKey32FromStoredValue(storedClientKey) { + const raw = String(storedClientKey || '').trim(); + if (!raw) throw new Error('Пустой client.key'); const jsonBytes = parseKeypairJson64(raw); if (jsonBytes) { diff --git a/shine-UI/js/services/crypto-utils.js b/shine-UI/js/services/crypto-utils.js index b1b7687..1d1b6d6 100644 --- a/shine-UI/js/services/crypto-utils.js +++ b/shine-UI/js/services/crypto-utils.js @@ -1,6 +1,7 @@ const encoder = new TextEncoder(); const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.'; import { argon2idAsync } from 'https://esm.sh/@noble/hashes@1.8.0/argon2.js'; +const SHINE_KEY_DERIVATION_PREFIX = 'SHiNE-key'; function getCryptoApi() { const api = globalThis.crypto; @@ -205,8 +206,19 @@ export async function deriveEd25519FromMasterSecret(masterSecret32, suffix) { if (secretBytes.length !== 32) { throw new Error('Master secret должен быть длиной 32 байта'); } - const material = `${bytesToBase64(secretBytes)}|${String(suffix || '')}`; - const seed = await sha256Text(material); + const suffixBytes = utf8Bytes(String(suffix || '')); + const material = new Uint8Array( + SHINE_KEY_DERIVATION_PREFIX.length + 1 + secretBytes.length + 1 + suffixBytes.length, + ); + let offset = 0; + material.set(utf8Bytes(SHINE_KEY_DERIVATION_PREFIX), offset); + offset += SHINE_KEY_DERIVATION_PREFIX.length; + material[offset++] = 0; + material.set(secretBytes, offset); + offset += secretBytes.length; + material[offset++] = 0; + material.set(suffixBytes, offset); + const seed = await sha256Bytes(material); const pkcs8 = ed25519Pkcs8FromSeed(seed); const subtle = getSubtleApi(); const privateKey = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']); diff --git a/shine-UI/js/services/device-pairing-service.js b/shine-UI/js/services/device-pairing-service.js index 5ee8600..fa307ab 100644 --- a/shine-UI/js/services/device-pairing-service.js +++ b/shine-UI/js/services/device-pairing-service.js @@ -71,7 +71,7 @@ async function importAesKeyFromSharedSecret(sharedSecretBytes) { function normalizeKeys(keys = {}) { return { - deviceKey: String(keys?.deviceKey || '').trim(), + clientKey: String(keys?.clientKey || '').trim(), blockchainKey: String(keys?.blockchainKey || '').trim(), rootKey: String(keys?.rootKey || '').trim(), }; diff --git a/shine-UI/js/services/qr-key-transfer-service.js b/shine-UI/js/services/qr-key-transfer-service.js index 7f74b59..5a24f51 100644 --- a/shine-UI/js/services/qr-key-transfer-service.js +++ b/shine-UI/js/services/qr-key-transfer-service.js @@ -30,7 +30,7 @@ export function keyLabel(id) { export function describeTransferKeys(keys = {}) { const out = []; - if (keys.deviceKey) out.push('device'); + if (keys.clientKey) out.push('device'); if (keys.blockchainKey) out.push('blockchain'); if (keys.rootKey) out.push('root'); return out; @@ -42,7 +42,7 @@ export function makeKeyTransferText({ login, keys }) { type: 'shine-key-transfer', login: String(login || '').trim(), keys: { - deviceKey: String(keys?.deviceKey || ''), + clientKey: String(keys?.clientKey || ''), blockchainKey: String(keys?.blockchainKey || ''), rootKey: String(keys?.rootKey || ''), }, @@ -65,13 +65,13 @@ export function parseKeyTransferText(text) { const login = String(payload.login || '').trim(); if (!login) throw new Error('В QR-коде нет логина'); const keys = payload.keys && typeof payload.keys === 'object' ? payload.keys : {}; - if (!keys.deviceKey && !keys.blockchainKey && !keys.rootKey) { + if (!keys.clientKey && !keys.blockchainKey && !keys.rootKey) { throw new Error('В QR-коде нет ключей'); } return { login, keys: { - deviceKey: String(keys.deviceKey || ''), + clientKey: String(keys.clientKey || ''), blockchainKey: String(keys.blockchainKey || ''), rootKey: String(keys.rootKey || ''), }, diff --git a/shine-UI/js/services/sawd-v1.js b/shine-UI/js/services/sawd-v1.js index f9bf68f..551279a 100644 --- a/shine-UI/js/services/sawd-v1.js +++ b/shine-UI/js/services/sawd-v1.js @@ -323,15 +323,15 @@ function toJwkB64(value) { return base64UrlEncode(bigIntToUnsignedBytes(value)); } -async function deriveArweaveWalletParts(deviceKey32) { - if (!(deviceKey32 instanceof Uint8Array)) { - throw new Error('SAWD-v1: deviceKey32 должен быть Uint8Array'); +async function deriveArweaveWalletParts(clientKey32) { + if (!(clientKey32 instanceof Uint8Array)) { + throw new Error('SAWD-v1: clientKey32 должен быть Uint8Array'); } - if (deviceKey32.length !== 32) { - throw new Error('SAWD-v1: deviceKey32 должен быть ровно 32 байта'); + if (clientKey32.length !== 32) { + throw new Error('SAWD-v1: clientKey32 должен быть ровно 32 байта'); } - const masterSeed32 = await hmacSha256(MASTER_LABEL_UTF8, deviceKey32); + const masterSeed32 = await hmacSha256(MASTER_LABEL_UTF8, clientKey32); const masterSeedKey = await importHmacKey(masterSeed32); const pResult = await derivePrimeWithImportedKey(masterSeedKey, 'p'); @@ -381,8 +381,8 @@ async function deriveArweaveWalletParts(deviceKey32) { }; } -export async function deriveArweaveWalletFromDeviceKey32(deviceKey32) { - const result = await deriveArweaveWalletParts(deviceKey32); +export async function deriveArweaveWalletFromClientKey32(clientKey32) { + const result = await deriveArweaveWalletParts(clientKey32); return { derivation: result.derivation, jwk: result.jwk, @@ -395,7 +395,7 @@ export async function selfTestSawdV1() { const invalid = new Uint8Array(31); let invalidFailed = false; try { - await deriveArweaveWalletFromDeviceKey32(invalid); + await deriveArweaveWalletFromClientKey32(invalid); } catch { invalidFailed = true; } @@ -403,13 +403,13 @@ export async function selfTestSawdV1() { throw new Error('SAWD-v1 self-test: длина != 32 должна приводить к ошибке'); } - const deviceKey = new Uint8Array(32); - for (let i = 0; i < deviceKey.length; i += 1) { - deviceKey[i] = i + 1; + const clientKey = new Uint8Array(32); + for (let i = 0; i < clientKey.length; i += 1) { + clientKey[i] = i + 1; } - const first = await deriveArweaveWalletParts(deviceKey); - const second = await deriveArweaveWalletParts(deviceKey); + const first = await deriveArweaveWalletParts(clientKey); + const second = await deriveArweaveWalletParts(clientKey); if (!first.address || !second.address) { throw new Error('SAWD-v1 self-test: адрес пустой'); diff --git a/shine-UI/js/services/shine-user-pda-service.js b/shine-UI/js/services/shine-user-pda-service.js index 24cb3b8..bb70c4e 100644 --- a/shine-UI/js/services/shine-user-pda-service.js +++ b/shine-UI/js/services/shine-user-pda-service.js @@ -1,5 +1,5 @@ import { base64ToBytes, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js'; -import { extractSeed32FromPkcs8B64 } from './device-key-utils.js'; +import { extractSeed32FromPkcs8B64 } from './client-key-utils.js'; import { SHINE_LOGIN_GUARD_PROGRAM_ID, SHINE_PAYMENTS_PROGRAM_ID, @@ -16,8 +16,9 @@ const BLOCKCHAIN_TYPE_MAIN_USER = 1; const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111'; const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111'; +const BLOCK_TYPE_RECOVERY_KEY = 0; const BLOCK_TYPE_ROOT_KEY = 1; -const BLOCK_TYPE_DEVICE_KEY = 2; +const BLOCK_TYPE_CLIENT_KEY = 2; const BLOCK_TYPE_BLOCKCHAIN_REGISTRY = 3; const BLOCK_TYPE_SERVER_PROFILE = 30; const BLOCK_TYPE_ACCESS_SERVERS = 40; @@ -176,10 +177,11 @@ function serializeCreateUserPdaArgs(args) { const buf = []; buf.push(3); pushStrU8(buf, args.login); + for (const x of args.recoveryKey32) buf.push(x); for (const x of args.rootKey32) buf.push(x); pushU64LE(buf, args.createdAtMs); pushU64LE(buf, BigInt(args.additionalLimitBytes || 0n)); - for (const x of args.deviceKey32) buf.push(x); + for (const x of args.clientKey32) buf.push(x); for (const x of args.blockchainPublicKey32) buf.push(x); pushStrU8(buf, args.blockchainName); pushU64LE(buf, args.usedBytes); @@ -218,13 +220,14 @@ function serializeUpdateUserPdaArgs(args) { const buf = []; buf.push(4); pushStrU8(buf, args.login); + for (const x of args.recoveryKey32) buf.push(x); for (const x of args.rootKey32) buf.push(x); pushU64LE(buf, args.createdAtMs); pushU64LE(buf, args.updatedAtMs); pushU32LE(buf, args.version); for (const x of args.prevHash32) buf.push(x); pushU64LE(buf, args.additionalLimitBytes); - for (const x of args.deviceKey32) buf.push(x); + for (const x of args.clientKey32) buf.push(x); for (const x of args.blockchainPublicKey32) buf.push(x); pushStrU8(buf, args.blockchainName); pushU64LE(buf, args.usedBytes); @@ -288,8 +291,9 @@ function createPdaState({ updatedAtMs, recordNumber, prevRecordHash, + recoveryKey, rootKey, - deviceKey, + clientKey, blockchain, isServer, addressFormatType, @@ -313,8 +317,10 @@ function createPdaState({ recordNumber, prevRecordHash, login, + recoveryKey, rootKey, - deviceKey, + clientKey, + clientKey: clientKey, blockchain, isServer: Boolean(isServer), serverProfile, @@ -373,8 +379,9 @@ export function parseShineUserPda(dataBytes) { const login = reader.readStrU8(); const blocksCount = reader.readU8(); + let recoveryKey = null; let rootKey = null; - let deviceKey = null; + let clientKey = null; let blockchain = null; let isServer = false; let addressFormatType = 0; @@ -390,12 +397,16 @@ export function parseShineUserPda(dataBytes) { const blockType = reader.readU8(); reader.readU8(); + if (blockType === BLOCK_TYPE_RECOVERY_KEY) { + recoveryKey = reader.readBytes(32); + continue; + } if (blockType === BLOCK_TYPE_ROOT_KEY) { rootKey = reader.readBytes(32); continue; } - if (blockType === BLOCK_TYPE_DEVICE_KEY) { - deviceKey = reader.readBytes(32); + if (blockType === BLOCK_TYPE_CLIENT_KEY) { + clientKey = reader.readBytes(32); continue; } if (blockType === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) { @@ -466,7 +477,7 @@ export function parseShineUserPda(dataBytes) { throw new Error(`Неизвестный блок PDA: ${blockType}`); } - if (!rootKey || !deviceKey || !blockchain) { + if (!recoveryKey || !rootKey || !clientKey || !blockchain) { throw new Error('В PDA отсутствуют обязательные блоки'); } @@ -478,8 +489,9 @@ export function parseShineUserPda(dataBytes) { updatedAtMs, recordNumber, prevRecordHash, + recoveryKey, rootKey, - deviceKey, + clientKey, blockchain, isServer, addressFormatType, @@ -507,8 +519,9 @@ export function serializeUnsignedRecordFromState(stateLike) { updatedAtMs: stateLike.updatedAtMs, recordNumber: stateLike.recordNumber, prevRecordHash: stateLike.prevRecordHash, + recoveryKey: stateLike.recoveryKey, rootKey: stateLike.rootKey, - deviceKey: stateLike.deviceKey, + clientKey: stateLike.clientKey ?? stateLike.clientKey, blockchain: stateLike.blockchain, isServer: stateLike.isServer, addressFormatType: stateLike.addressFormatType ?? stateLike.serverProfile?.addressFormatType, @@ -527,13 +540,16 @@ export function serializeUnsignedRecordFromState(stateLike) { pushU32LE(buf, state.recordNumber); for (const x of state.prevRecordHash) buf.push(x); pushStrU8(buf, state.login); - buf.push(state.isServer ? 7 : 6); + buf.push(state.isServer ? 8 : 7); + + buf.push(BLOCK_TYPE_RECOVERY_KEY, 0); + for (const x of state.recoveryKey) buf.push(x); buf.push(BLOCK_TYPE_ROOT_KEY, 0); for (const x of state.rootKey) buf.push(x); - buf.push(BLOCK_TYPE_DEVICE_KEY, 0); - for (const x of state.deviceKey) buf.push(x); + buf.push(BLOCK_TYPE_CLIENT_KEY, 0); + for (const x of state.clientKey) buf.push(x); buf.push(BLOCK_TYPE_BLOCKCHAIN_REGISTRY, 0, 1, state.blockchain.blockchainType); pushStrU8(buf, state.blockchain.blockchainName); @@ -656,14 +672,15 @@ async function buildCreateContext({ login, keyBundle, solanaEndpoint }) { const [economyConfigPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_ECONOMY_CONFIG_SEED)], usersProgram); const [inflowVault] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_PAYMENTS_INFLOW_VAULT_SEED)], paymentsProgram); + const recoveryKey32 = base64ToBytes(keyBundle.recoveryPair.publicKeyB64); const rootKey32 = base64ToBytes(keyBundle.rootPair.publicKeyB64); const blockchainKey32 = base64ToBytes(keyBundle.blockchainPair.publicKeyB64); - const deviceKey32 = base64ToBytes(keyBundle.devicePair.publicKeyB64); + const clientKey32 = base64ToBytes(keyBundle.clientPair.publicKeyB64); const rootPrivKey = await importPkcs8Ed25519(keyBundle.rootPair.privatePkcs8B64); const bchPrivKey = await importPkcs8Ed25519(keyBundle.blockchainPair.privatePkcs8B64); - const deviceSeed32 = extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64); - const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32); + const clientSeed32 = extractSeed32FromPkcs8B64(keyBundle.clientPair.privatePkcs8B64); + const clientKeypair = solana.Keypair.fromSeed(clientSeed32); return { cleanLogin, @@ -678,12 +695,13 @@ async function buildCreateContext({ login, keyBundle, solanaEndpoint }) { userPda, economyConfigPda, inflowVault, + recoveryKey32, rootKey32, blockchainKey32, - deviceKey32, + clientKey32, rootPrivKey, bchPrivKey, - deviceKeypair, + clientKeypair, }; } @@ -720,8 +738,9 @@ async function createShineUserPdaOnSolana({ updatedAtMs: createdAtMs, recordNumber: 0, prevRecordHash: zeroHash32, + recoveryKey: ctx.recoveryKey32, rootKey: ctx.rootKey32, - deviceKey: ctx.deviceKey32, + clientKey: ctx.clientKey32, blockchain: createBlockchainState({ blockchainName, blockchainPublicKey: ctx.blockchainKey32, @@ -749,10 +768,11 @@ async function createShineUserPdaOnSolana({ const ixData = serializeCreateUserPdaArgs({ login: cleanLogin, + recoveryKey32: ctx.recoveryKey32, rootKey32: ctx.rootKey32, createdAtMs, additionalLimitBytes: 0n, - deviceKey32: ctx.deviceKey32, + clientKey32: ctx.clientKey32, blockchainPublicKey32: ctx.blockchainKey32, blockchainName, usedBytes: 0n, @@ -785,7 +805,7 @@ async function createShineUserPdaOnSolana({ const createIx = new ctx.solana.TransactionInstruction({ programId: ctx.usersProgram, keys: [ - { pubkey: ctx.deviceKeypair.publicKey, isSigner: true, isWritable: true }, + { pubkey: ctx.clientKeypair.publicKey, isSigner: true, isWritable: true }, { pubkey: ctx.userPda, isSigner: false, isWritable: true }, { pubkey: ctx.solana.SystemProgram.programId, isSigner: false, isWritable: false }, { pubkey: ctx.inflowVault, isSigner: false, isWritable: true }, @@ -799,7 +819,7 @@ async function createShineUserPdaOnSolana({ const signature = await ctx.solana.sendAndConfirmTransaction( ctx.connection, new ctx.solana.Transaction().add(ed25519RootIx, ed25519BchIx, createIx), - [ctx.deviceKeypair], + [ctx.clientKeypair], { commitment: 'confirmed' }, ); @@ -848,7 +868,7 @@ export async function updateShineUserPdaOnSolana({ login, solanaEndpoint, rootPrivatePkcs8B64, - devicePrivatePkcs8B64, + clientPrivatePkcs8B64, blockchainPrivatePkcs8B64, additionalLimitBytes = 0n, nextUsedBytes, @@ -882,8 +902,8 @@ export async function updateShineUserPdaOnSolana({ const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_USER_PDA_SEED_PREFIX), enc.encode(cleanLogin)], usersProgram); const [economyPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_ECONOMY_CONFIG_SEED)], usersProgram); const [inflowVault] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_PAYMENTS_INFLOW_VAULT_SEED)], paymentsProgram); - const deviceSeed32 = extractSeed32FromPkcs8B64(devicePrivatePkcs8B64); - const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32); + const clientSeed32 = extractSeed32FromPkcs8B64(clientPrivatePkcs8B64); + const clientKeypair = solana.Keypair.fromSeed(clientSeed32); const lastBlockStateBytes = buildLastBlockStateBytes( cleanLogin, @@ -928,8 +948,9 @@ export async function updateShineUserPdaOnSolana({ updatedAtMs, recordNumber: newRecordNumber, prevRecordHash: prevHash, + recoveryKey: current.recoveryKey, rootKey: current.rootKey, - deviceKey: current.deviceKey, + clientKey: current.clientKey ?? current.clientKey, blockchain: createBlockchainState({ blockchainName: currentBch.blockchainName, blockchainPublicKey: currentBch.blockchainPublicKey, @@ -957,13 +978,14 @@ export async function updateShineUserPdaOnSolana({ const ixData = serializeUpdateUserPdaArgs({ login: cleanLogin, + recoveryKey32: current.recoveryKey, rootKey32: current.rootKey, createdAtMs: current.createdAtMs, updatedAtMs, version: newRecordNumber, prevHash32: prevHash, additionalLimitBytes: addLimit, - deviceKey32: current.deviceKey, + clientKey32: current.clientKey, blockchainPublicKey32: currentBch.blockchainPublicKey, blockchainName: currentBch.blockchainName, usedBytes: effectiveUsed, @@ -996,7 +1018,7 @@ export async function updateShineUserPdaOnSolana({ const updateIx = new solana.TransactionInstruction({ programId: usersProgram, keys: [ - { pubkey: deviceKeypair.publicKey, isSigner: true, isWritable: true }, + { pubkey: clientKeypair.publicKey, isSigner: true, isWritable: true }, { pubkey: userPda, isSigner: false, isWritable: true }, { pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false }, { pubkey: inflowVault, isSigner: false, isWritable: true }, @@ -1011,7 +1033,7 @@ export async function updateShineUserPdaOnSolana({ const signature = await solana.sendAndConfirmTransaction( connection, new solana.Transaction().add(computeIx, heapIx, edIxRoot, edIxBch, updateIx), - [deviceKeypair], + [clientKeypair], { commitment: 'confirmed' }, ); @@ -1040,7 +1062,7 @@ export async function updateServerOnSolana({ login, solanaEndpoint, rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64, - devicePrivatePkcs8B64: keyBundle.devicePair.privatePkcs8B64, + clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64, serverProfile: { addressFormatType, addressFormatVersion, diff --git a/shine-UI/js/services/solana-wallet-service.js b/shine-UI/js/services/solana-wallet-service.js index 4582a18..e0d2da3 100644 --- a/shine-UI/js/services/solana-wallet-service.js +++ b/shine-UI/js/services/solana-wallet-service.js @@ -1,4 +1,4 @@ -import { extractDeviceKey32FromStoredValue } from './device-key-utils.js'; +import { extractClientKey32FromStoredValue } from './client-key-utils.js'; import { loadEncryptedUserSecrets } from './key-vault.js'; import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js'; @@ -63,7 +63,7 @@ function decodeBase58(input) { async function keypairFromPkcs8(pkcs8B64) { const solana = await loadSolanaLib(); - const seed32 = extractDeviceKey32FromStoredValue(pkcs8B64); + const seed32 = extractClientKey32FromStoredValue(pkcs8B64); return solana.Keypair.fromSeed(seed32); } @@ -95,22 +95,22 @@ export async function createSolanaWalletFromPrivateBase58(privateKey32Base58) { }; } -export async function getWalletFromStoredDeviceKey({ login, storagePwd }) { +export async function getWalletFromStoredClientKey({ login, storagePwd }) { const cleanLogin = String(login || '').trim(); const cleanPwd = String(storagePwd || '').trim(); if (!cleanLogin || !cleanPwd) { - throw new Error('Нет активной сессии для доступа к wallet.key'); + throw new Error('Нет активной сессии для доступа к client.key'); } const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd); - const devicePrivate = String(secrets?.deviceKey || '').trim(); - if (!devicePrivate) { - throw new Error('На устройстве не найден device.key (wallet.key)'); + const clientPrivate = String(secrets?.clientKey || '').trim(); + if (!clientPrivate) { + throw new Error('На устройстве не найден client.key'); } - const keypair = await keypairFromPkcs8(devicePrivate); + const keypair = await keypairFromPkcs8(clientPrivate); return { address: keypair.publicKey.toBase58(), keypair, - devicePrivatePkcs8B64: devicePrivate, + clientPrivatePkcs8B64: clientPrivate, }; } diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index 6ec8784..102dc7a 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -289,7 +289,7 @@ function createInitialState({ withStoredSession = true } = {}) { keyStorage: { rootKey: 'Ключ root хранится в зашифрованном виде', blockchainKey: 'Ключ blockchain хранится в зашифрованном виде', - deviceKey: 'Ключ device хранится в зашифрованном виде', + clientKey: 'Ключ device хранится в зашифрованном виде', saveRoot: false, saveBlockchain: true, saveDevice: true, diff --git a/shine-UI/server-ui.html b/shine-UI/server-ui.html index 9b117c8..9df708b 100644 --- a/shine-UI/server-ui.html +++ b/shine-UI/server-ui.html @@ -47,10 +47,10 @@

Что потребуется

- Для создания: полный keyBundle сервера (rootPair + devicePair + blockchainPair), + Для создания: полный keyBundle сервера (rootPair + clientPair + blockchainPair), логин сервера (без точки, не более 20 символов), URL-адрес сервера, Solana-эндпоинт, достаточный баланс SOL на device-ключе для комиссии.

- Для обновления: только rootPair + devicePair (blockchain-ключ не нужен). + Для обновления: только rootPair + clientPair (blockchain-ключ не нужен).

diff --git a/shine-UI/server-ui/js/server-ui-shared.js b/shine-UI/server-ui/js/server-ui-shared.js index e8415b2..5752ec3 100644 --- a/shine-UI/server-ui/js/server-ui-shared.js +++ b/shine-UI/server-ui/js/server-ui-shared.js @@ -111,6 +111,18 @@ async function pairFromSeedBase58(seedB58, explicitPubB58) { export async function buildKeyBundleFromForm(fieldMap, options = {}) { const requireBlockchain = options.requireBlockchain !== false; + let recovery = null; + const masterSecretValue = fieldMap.masterSecret ? String($(fieldMap.masterSecret).value || '').trim() : ''; + if (masterSecretValue) { + const masterSecret32 = ensure32Bytes(base58ToBytes(masterSecretValue)); + const recoveryPair = await deriveEd25519FromMasterSecret(masterSecret32, 'recovery.key'); + recovery = { + publicKeyB64: recoveryPair.publicKeyB64, + privatePkcs8B64: recoveryPair.privatePkcs8B64, + publicKeyB58: bytesToBase58(base64ToBytes(recoveryPair.publicKeyB64)), + privateSeedB58: bytesToBase58(base64ToBytes(recoveryPair.privatePkcs8B64).slice(-32)), + }; + } const root = await pairFromSeedBase58($(fieldMap.rootPriv).value, $(fieldMap.rootPub).value); const device = await pairFromSeedBase58($(fieldMap.devPriv).value, $(fieldMap.devPub).value); const blockchainPriv = String($(fieldMap.bchPriv).value || '').trim(); @@ -122,13 +134,18 @@ export async function buildKeyBundleFromForm(fieldMap, options = {}) { } return { keyBundle: { + recoveryPair: recovery + ? { publicKeyB64: recovery.publicKeyB64, privatePkcs8B64: recovery.privatePkcs8B64 } + : null, rootPair: { publicKeyB64: root.publicKeyB64, privatePkcs8B64: root.privatePkcs8B64 }, blockchainPair: blockchain ? { publicKeyB64: blockchain.publicKeyB64, privatePkcs8B64: blockchain.privatePkcs8B64 } : null, - devicePair: { publicKeyB64: device.publicKeyB64, privatePkcs8B64: device.privatePkcs8B64 }, + clientPair: { publicKeyB64: device.publicKeyB64, privatePkcs8B64: device.privatePkcs8B64 }, }, normalized: { + recoveryPubB58: recovery?.publicKeyB58 || '', + recoveryPrivB58: recovery?.privateSeedB58 || '', rootPubB58: root.publicKeyB58, rootPrivB58: root.privateSeedB58, bchPubB58: blockchain?.publicKeyB58 || '', @@ -148,14 +165,20 @@ export async function deriveKeyBundleFromPassword({ login, password, onProgress login: cleanLogin, onProgress, }); - const [rootPair, blockchainPair, devicePair] = await Promise.all([ + const [recoveryPair, rootPair, blockchainPair, clientPair] = await Promise.all([ + deriveEd25519FromMasterSecret(masterSecret32, 'recovery.key'), deriveEd25519FromMasterSecret(masterSecret32, 'root.key'), - deriveEd25519FromMasterSecret(masterSecret32, 'bch.key'), - deriveEd25519FromMasterSecret(masterSecret32, 'dev.key'), + deriveEd25519FromMasterSecret(masterSecret32, 'blockchain.key'), + deriveEd25519FromMasterSecret(masterSecret32, 'client.key'), ]); return { masterSecret32, - keyBundle: { rootPair, blockchainPair, devicePair }, + keyBundle: { + recoveryPair, + rootPair, + blockchainPair, + clientPair, + }, }; } @@ -163,12 +186,16 @@ export function fillKeyFields(fieldMap, keyBundle, masterSecret32) { if (masterSecret32) { $(fieldMap.masterSecret).value = bytesToBase58(masterSecret32); } + if (fieldMap.recoveryPub && fieldMap.recoveryPriv && keyBundle.recoveryPair) { + $(fieldMap.recoveryPub).value = bytesToBase58(base64ToBytes(keyBundle.recoveryPair.publicKeyB64)); + $(fieldMap.recoveryPriv).value = bytesToBase58(base64ToBytes(keyBundle.recoveryPair.privatePkcs8B64).slice(-32)); + } $(fieldMap.rootPub).value = bytesToBase58(base64ToBytes(keyBundle.rootPair.publicKeyB64)); $(fieldMap.rootPriv).value = bytesToBase58(base64ToBytes(keyBundle.rootPair.privatePkcs8B64).slice(-32)); $(fieldMap.bchPub).value = bytesToBase58(base64ToBytes(keyBundle.blockchainPair.publicKeyB64)); $(fieldMap.bchPriv).value = bytesToBase58(base64ToBytes(keyBundle.blockchainPair.privatePkcs8B64).slice(-32)); - $(fieldMap.devPub).value = bytesToBase58(base64ToBytes(keyBundle.devicePair.publicKeyB64)); - $(fieldMap.devPriv).value = bytesToBase58(base64ToBytes(keyBundle.devicePair.privatePkcs8B64).slice(-32)); + $(fieldMap.devPub).value = bytesToBase58(base64ToBytes(keyBundle.clientPair.publicKeyB64)); + $(fieldMap.devPriv).value = bytesToBase58(base64ToBytes(keyBundle.clientPair.privatePkcs8B64).slice(-32)); } export function updateSolAddress(fieldMap) { diff --git a/shine-UI/server-ui/js/update-server-pda-page.js b/shine-UI/server-ui/js/update-server-pda-page.js index 821a222..0f8b477 100644 --- a/shine-UI/server-ui/js/update-server-pda-page.js +++ b/shine-UI/server-ui/js/update-server-pda-page.js @@ -50,7 +50,7 @@ function renderExpectedKeys(parsed) { $('expectedKeysBox').style.display = 'block'; setText('expectedRootPub', publicKeyBytesToBase58(parsed.rootKey)); setText('expectedBchPub', publicKeyBytesToBase58(parsed.blockchain.blockchainPublicKey)); - setText('expectedDevPub', publicKeyBytesToBase58(parsed.deviceKey)); + setText('expectedDevPub', publicKeyBytesToBase58(parsed.clientKey)); setGenMessage($('expectedKeysStatus'), 'После генерации ключей этот блок покажет, совпадают ли они с уже записанной PDA.', 'warn'); } @@ -63,7 +63,7 @@ function compareCurrentFormKeysWithPda() { blockchain: blockchainActual ? compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.blockchain.blockchainPublicKey), blockchainActual) : { matches: true, expected: publicKeyBytesToBase58(currentPda.blockchain.blockchainPublicKey), actual: '' }, - device: compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.deviceKey), $('devPub').value), + device: compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.clientKey), $('devPub').value), }, }; } diff --git a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md index 556e0c4..6039f04 100644 --- a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md +++ b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md @@ -12,8 +12,9 @@ - логин пользователя; - неизменяемые параметры создания записи; +- публичный recovery-ключ пользователя; - корневой публичный ключ пользователя; -- ключ устройства; +- клиентский публичный ключ пользователя; - данные одного или нескольких пользовательских блокчейнов SHiNE; - серверные данные пользователя, если пользователь выступает сервером; - серверы доступа пользователя; @@ -34,9 +35,9 @@ ## 2.1. Кто оплачивает create/update PDA -- Инструкции `create_user_pda` и `update_user_pda` оплачиваются с `device_key`. +- Инструкции `create_user_pda` и `update_user_pda` оплачиваются с `client_key`. - `root_key` используется для подписи unsigned части записи через Ed25519 instruction и не является fee payer. -- Для server PDA это правило то же самое: пополнять SOL нужно на адрес `device_key`. +- Для server PDA это правило то же самое: пополнять SOL нужно на адрес `client_key`. ## 3. Общие правила кодирования @@ -85,8 +86,9 @@ UserPdaRecordV1 | block_type | Блок | Назначение | |------------|------|------------| +| `0` | `RecoveryKeyBlock` | Ключ восстановления пользователя. | | `1` | `RootKeyBlock` | Корневой ключ пользователя. | -| `2` | `DeviceKeyBlock` | Ключ устройства пользователя. | +| `2` | `ClientKeyBlock` | Клиентский ключ пользователя. | | `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. | | `30` | `ServerProfileBlock` | Серверные данные пользователя. | | `40` | `AccessServersBlock` | Серверы доступа/relay. | @@ -97,13 +99,31 @@ UserPdaRecordV1 Правила: - неизвестный `block_type` в `format_major = 1` считается ошибкой; -- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`; +- обязательные блоки: `RecoveryKeyBlock`, `RootKeyBlock`, `ClientKeyBlock`, `BlockchainRegistryBlock`; - необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`; - каждый обязательный блок должен встречаться ровно один раз; - порядок блоков в записи фиксируется для простоты проверки: - `RootKey`, `DeviceKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`. + `RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`. -## 6. RootKeyBlock +## 6. RecoveryKeyBlock + +Recovery-ключ нужен для будущих сценариев восстановления и ротации остальных ключей. В текущей версии он только публикуется в записи и не меняется через обычный `update_user_pda`. + +```text +RecoveryKeyBlock +- block_type: u8 = 0 +- block_version: u8 = 0 +- recovery_key: [u8; 32] +``` + +Правила: + +- при создании задается публичный recovery-ключ пользователя; +- при обновлении `recovery_key` должен совпадать с предыдущей записью; +- приватный `recovery.key` в PDA не хранится; +- отдельная ротация recovery-ключа будет отдельным форматом/сценарием в будущем. + +## 7. RootKeyBlock Смена `root_key` пока не проектируется и не реализуется. Блок фиксирует только стадию `0`. @@ -120,24 +140,24 @@ RootKeyBlock - при обновлении `root_key` должен совпадать с предыдущей записью; - ротация root-key будет отдельным форматом/сценарием в будущем. -## 7. DeviceKeyBlock +## 8. ClientKeyBlock -Смена `device_key` пока также не проектируется как отдельная ротация. В версии `0` хранится один ключ устройства. +Смена `client_key` пока также не проектируется как отдельная ротация. В версии `0` хранится один клиентский ключ пользователя. ```text -DeviceKeyBlock +ClientKeyBlock - block_type: u8 = 2 - block_version: u8 = 0 -- device_key: [u8; 32] +- client_key: [u8; 32] ``` Правила: -- при создании задается текущий публичный ключ устройства; -- при обновлении ключ устройства может быть обновлен только если это отдельно разрешено бизнес-логикой инструкции; -- история устройств и несколько устройств в этом формате не хранятся. +- при создании задается текущий клиентский публичный ключ пользователя; +- при обновлении `client_key` должен совпадать с предыдущей записью; +- история устройств и несколько клиентских ключей в этом формате не хранятся. -## 8. BlockchainRegistryBlock +## 9. BlockchainRegistryBlock Блок хранит данные пользовательских блокчейнов SHiNE. Сейчас используется один блокчейн, но структура сразу сделана как список. @@ -155,7 +175,7 @@ BlockchainRegistryBlock - в будущем можно увеличить количество записей без изменения смысла `BlockchainRecord`; - каждый `BlockchainRecord` описывает один пользовательский SHiNE-блокчейн. -## 9. BlockchainRecord +## 10. BlockchainRecord ```text BlockchainRecord @@ -191,7 +211,7 @@ BlockchainRecord Arweave `tx_id` - обычное поле внутри записи конкретного блокчейна. Solana-программа не проверяет, что такой Arweave transaction действительно существует и содержит корректные данные; это ответственность клиента/сервера/пользователя. -## 10. Правила обновления BlockchainRecord +## 11. Правила обновления BlockchainRecord При обновлении записи: @@ -229,7 +249,7 @@ last_block_signature = Ed25519(blockchain_public_key, message) Причина проверки подписи `LastBlockState`: `root_key` управляет Solana-записью пользователя, а `blockchain_public_key` подтверждает состояние конкретного пользовательского блокчейна. Подписывается не голый хэш, а связка логина, имени блокчейна, номера последнего блока, хэша последнего блока и занятого размера. -## 11. ServerProfileBlock +## 12. ServerProfileBlock Блок присутствует, если пользователь выступает сервером. @@ -255,7 +275,7 @@ ServerProfileBlock - `server_address` - строковый адрес сервера в соответствии с `address_format_type`; - `sync_servers` - логины SHiNE-пользователей, зарегистрированных как серверы, с которыми этот сервер синхронизирует блокчейн и личные сообщения. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы. -## 12. AccessServersBlock +## 13. AccessServersBlock Блок хранит серверы доступа/relay для пользователя. @@ -274,7 +294,7 @@ AccessServersBlock - `access_servers` - логины пользователей системы, используемых как серверы доступа/relay. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы; - точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE. -## 13. SessionsBlock +## 14. SessionsBlock Блок хранит опубликованные пользовательские сессии. На текущем этапе регистрация пользователя не добавляет туда записи автоматически, поэтому стандартный create/update продолжает работать с пустым списком. @@ -321,7 +341,7 @@ SessionRecord - внутри одного блока должны быть уникальны и `session_name`, и `session_pub_key`; - на текущем этапе UI и регистрация не обязаны добавлять туда записи автоматически. -## 14. TrustedStateBlock +## 15. TrustedStateBlock Пока trusted-логика не реализована полностью, поэтому блок хранит только счетчик. @@ -334,7 +354,7 @@ TrustedStateBlock Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат. -## 15. Подпись user_pda +## 16. Подпись user_pda Подписывается не вся PDA целиком, а unsigned-часть записи: @@ -355,7 +375,7 @@ Solana-программа проверяет подпись через встр Смену формата подписи сейчас не трогаем. -## 16. Регистрация пользователя +## 17. Регистрация пользователя При регистрации: @@ -373,12 +393,12 @@ Solana-программа проверяет подпись через встр - если покупается дополнительный лимит, пользователь платит комиссию за этот лимит; - вся unsigned-часть записи подписана `root_key`. -## 17. Обновление пользователя +## 18. Обновление пользователя При обновлении: - PDA должна существовать; -- `login`, `created_at_ms`, `root_key` не меняются; +- `login`, `created_at_ms`, `recovery_key`, `root_key`, `client_key` не меняются; - `record_number = previous_record_number + 1`; - `prev_record_hash` равен хэшу unsigned-части предыдущей записи; - `updated_at_ms` обновляется; @@ -388,7 +408,7 @@ Solana-программа проверяет подпись через встр - при увеличении оплаченного лимита пользователь доплачивает комиссию; - Arweave `tx_id` может быть пустым или обновленным, но его содержимое Solana не валидирует. -## 18. Отличия от старого линейного формата +## 19. Отличия от старого линейного формата Старый формат после `login` хранил поля линейно: @@ -396,8 +416,8 @@ Solana-программа проверяет подпись через встр - `root_key`; - `blockchain_key_status`; - `blockchain_key`; -- `device_key_status`; -- `device_key`; +- `client_key_status`; +- `client_key`; - `chain_number`; - `balance`; - серверные поля; @@ -408,17 +428,54 @@ Solana-программа проверяет подпись через встр Новый целевой формат сохраняет первые 9 фиксированных полей как заголовок, но дальше переходит на типизированные блоки: +- recovery-ключ становится отдельным обязательным блоком; - ключи становятся отдельными блоками; - данные блокчейна становятся расширенным блоком со своим публичным ключом, лимитом, занятым размером, вершиной цепочки и Arweave `tx_id`; - серверные данные и access-серверы отделяются от данных блокчейна; - расширение формата делается добавлением новых версий блоков или новых `block_type`, а не вставкой полей в середину линейной записи. -## 18. Что пока не входит в формат +## 20. Деривация ключей из master secret + +Сама Solana-программа не вычисляет ключи из секрета и не хранит приватные ключи. Но текущая согласованная клиентская схема деривации для публичной версии формата фиксируется здесь как reference для UI/ESP32/внешних клиентов. + +Базовая формула: + +```text +seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || suffix_utf8) +``` + +Где: + +- `master_secret32` — 32-байтовый master secret пользователя; +- `suffix_utf8` — строка назначения ключа. + +Согласованные suffix: + +```text +"recovery.key" +"root.key" +"blockchain.key" +"client.key" +``` + +Соответствие: + +```text +recovery.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "recovery.key") +root.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "root.key") +blockchain.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "blockchain.key") +client.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "client.key") +``` + +Далее каждая строка `seed` интерпретируется off-chain как `seed32` для отдельной пары Ed25519. + +## 21. Что пока не входит в формат Пока не проектируем: +- ротацию `recovery_key`; - ротацию `root_key`; -- сложную ротацию `device_key`; +- сложную ротацию `client_key`; - ротацию `blockchain_public_key`; - проверку содержимого Arweave transaction; - хранение полной истории пользовательского блокчейна внутри Solana; diff --git a/shine-solana/shine/doc/programs/shine_users.md b/shine-solana/shine/doc/programs/shine_users.md index e53e43d..fa6ae35 100644 --- a/shine-solana/shine/doc/programs/shine_users.md +++ b/shine-solana/shine/doc/programs/shine_users.md @@ -154,11 +154,13 @@ system-переводом. Если бы создание шло строго ч ## 6. Ключи и подписи -В записи участвуют три ключевых роли: +В записи участвуют четыре ключевых роли: +- `recovery_key` + - публичный recovery-ключ пользователя для будущих сценариев восстановления; - `root_key` - корневая подпись самой записи; -- `device_key` +- `client_key` - текущий плательщик и signer транзакции create/update; - `blockchain_public_key` - ключ подтверждения вершины пользовательского SHiNE-блокчейна. @@ -176,11 +178,26 @@ system-переводом. Если бы создание шло строго ч - PKCS#8 контейнеры; - PEM; - способ хранения приватного ключа на клиенте; -- откуда клиент извлёк `seed32` для `device_key`. +- откуда клиент извлёк `seed32` для `client_key`. ### 6.2. Практика клиентской генерации ключей -Off-chain клиентская логика может хранить приватные ключи в PKCS#8 и извлекать `seed32` для `device`-signer. Это допустимая клиентская реализация, но не часть on-chain формата. +Off-chain клиентская логика может хранить приватные ключи в PKCS#8 и извлекать `seed32` для `client`-signer. Это допустимая клиентская реализация, но не часть on-chain формата. + +Согласованная клиентская схема деривации для первой публичной версии: + +```text +seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || suffix_utf8) +``` + +Согласованные suffix: + +```text +"recovery.key" +"root.key" +"blockchain.key" +"client.key" +``` On-chain инвариант только один: @@ -277,16 +294,16 @@ On-chain инвариант только один: Плательщик транзакции и signer инструкции: -- `device_key` +- `client_key` Это принципиальное правило: - `root_key` только подписывает запись; -- `device_key` оплачивает rent/fees/registration flow. +- `client_key` оплачивает rent/fees/registration flow. ### Аккаунты -- signer = `device_key` +- signer = `client_key` - `user_pda` - system program - inflow vault PDA из `shine_payments` @@ -297,6 +314,7 @@ On-chain инвариант только один: ### Входные данные - логин +- `recovery_key` - `root_key` - `created_at_ms` - `additional_limit` @@ -308,6 +326,7 @@ On-chain инвариант только один: ```text - tag: u8 = 3 - login: string_u8 +- recovery_key: [u8; 32] - root_key: [u8; 32] - created_at_ms: u64 LE - additional_limit: u64 LE @@ -368,13 +387,13 @@ limit_fee(additional_limit) = (additional_limit / LIMIT_STEP) * lamports_per_lim Те же роли: -- signer/fee payer = `device_key` +- signer/fee payer = `client_key` - подпись записи = `root_key` - подпись вершины блокчейна = `blockchain_public_key` ### Аккаунты -- signer = `device_key` +- signer = `client_key` - `user_pda` - system program - inflow vault PDA из `shine_payments` @@ -386,17 +405,19 @@ limit_fee(additional_limit) = (additional_limit / LIMIT_STEP) * lamports_per_lim 1. PDA существует и принадлежит `shine_users`. 2. Новый логин совпадает со старым. 3. `created_at_ms` совпадает со старым. -4. `root_key` совпадает со старым. -5. `version = old.record_number + 1`. -6. `prev_hash = hash(unsigned_old_record)`. -7. `additional_limit % LIMIT_STEP == 0`. -8. `blockchain_name` и `blockchain_public_key` не меняются. -9. `paid_limit_bytes` не уменьшается. -10. `used_bytes` не уменьшается. -11. `last_block_number` не уменьшается. -12. Если состояние блокчейна изменилось, `last_block_signature` заново проверяется через Ed25519. -13. Новая unsigned часть записи подписана `root_key`. -14. При необходимости PDA может быть расширена через realloc. +4. `recovery_key` совпадает со старым. +5. `root_key` совпадает со старым. +6. `client_key` совпадает со старым. +7. `version = old.record_number + 1`. +8. `prev_hash = hash(unsigned_old_record)`. +9. `additional_limit % LIMIT_STEP == 0`. +10. `blockchain_name` и `blockchain_public_key` не меняются. +11. `paid_limit_bytes` не уменьшается. +12. `used_bytes` не уменьшается. +13. `last_block_number` не уменьшается. +14. Если состояние блокчейна изменилось, `last_block_signature` заново проверяется через Ed25519. +15. Новая unsigned часть записи подписана `root_key`. +16. При необходимости PDA может быть расширена через realloc. ### Экономика @@ -413,6 +434,7 @@ topup_fee = limit_fee(additional_limit) ```text - tag: u8 = 4 - login: string_u8 +- recovery_key: [u8; 32] - root_key: [u8; 32] - created_at_ms: u64 LE - updated_at_ms: u64 LE @@ -461,7 +483,6 @@ signature = Ed25519(blockchain_private_key, message_hash) Программа допускает обновление: -- `device_key` - `used_bytes` - `last_block_number` - `last_block_hash` @@ -479,7 +500,9 @@ signature = Ed25519(blockchain_private_key, message_hash) - `login` - `created_at_ms` +- `recovery_key` - `root_key` +- `client_key` - `blockchain_name` - `blockchain_public_key` - `blockchain_type` @@ -489,7 +512,7 @@ signature = Ed25519(blockchain_private_key, message_hash) ### UserMutableFieldsV1 ```text -- device_key: [u8; 32] +- client_key: [u8; 32] - blockchain_public_key: [u8; 32] - blockchain_name: string_u8 - used_bytes: u64 LE diff --git a/shine-solana/shine/programs/shine_users/src/lib.rs b/shine-solana/shine/programs/shine_users/src/lib.rs index eeb869e..b3b2fa0 100644 --- a/shine-solana/shine/programs/shine_users/src/lib.rs +++ b/shine-solana/shine/programs/shine_users/src/lib.rs @@ -28,8 +28,9 @@ const MAX_SESSIONS: usize = 64; const MAX_SESSION_NAME_LEN: usize = 64; const MAX_AUTO_REALLOC_INCREASE: usize = 10_000; const ZERO_HASH: [u8; 32] = [0; 32]; +const BLOCK_TYPE_RECOVERY_KEY: u8 = 0; const BLOCK_TYPE_ROOT_KEY: u8 = 1; -const BLOCK_TYPE_DEVICE_KEY: u8 = 2; +const BLOCK_TYPE_CLIENT_KEY: u8 = 2; const BLOCK_TYPE_BLOCKCHAIN_REGISTRY: u8 = 3; const BLOCK_TYPE_SERVER_PROFILE: u8 = 30; const BLOCK_TYPE_ACCESS_SERVERS: u8 = 40; @@ -113,7 +114,7 @@ pub struct SessionRecord { #[derive(Clone, Debug)] pub struct UserMutableFields { - pub device_key: Pubkey, + pub client_key: Pubkey, pub blockchain_public_key: Pubkey, pub blockchain_name: String, pub used_bytes: u64, @@ -135,6 +136,7 @@ pub struct UserMutableFields { #[derive(Clone, Debug)] pub struct CreateUserPdaArgs { pub login: String, + pub recovery_key: Pubkey, pub root_key: Pubkey, pub created_at_ms: u64, pub additional_limit: u64, @@ -145,6 +147,7 @@ pub struct CreateUserPdaArgs { #[derive(Clone, Debug)] pub struct UpdateUserPdaArgs { pub login: String, + pub recovery_key: Pubkey, pub root_key: Pubkey, pub created_at_ms: u64, pub updated_at_ms: u64, @@ -190,8 +193,9 @@ pub struct UserRecord { pub record_number: u32, pub prev_record_hash: [u8; 32], pub login: String, + pub recovery_key: Pubkey, pub root_key: Pubkey, - pub device_key: Pubkey, + pub client_key: Pubkey, pub blockchain: BlockchainRecord, pub is_server: bool, pub address_format_type: u8, @@ -291,6 +295,7 @@ fn process_instruction(program_id: &Pubkey, accounts: &[AccountInfo], instructio fn parse_create_args(r: &mut Reader<'_>) -> Result { Ok(CreateUserPdaArgs { login: r.read_string_u8()?, + recovery_key: r.read_pubkey()?, root_key: r.read_pubkey()?, created_at_ms: r.read_u64()?, additional_limit: r.read_u64()?, @@ -302,6 +307,7 @@ fn parse_create_args(r: &mut Reader<'_>) -> Result) -> Result { Ok(UpdateUserPdaArgs { login: r.read_string_u8()?, + recovery_key: r.read_pubkey()?, root_key: r.read_pubkey()?, created_at_ms: r.read_u64()?, updated_at_ms: r.read_u64()?, @@ -314,7 +320,7 @@ fn parse_update_args(r: &mut Reader<'_>) -> Result) -> Result { - let device_key = r.read_pubkey()?; + let client_key = r.read_pubkey()?; let blockchain_public_key = r.read_pubkey()?; let blockchain_name = r.read_string_u8()?; let used_bytes = r.read_u64()?; @@ -350,7 +356,7 @@ fn parse_fields(r: &mut Reader<'_>) -> Result { } let trusted_count = r.read_u8()?; Ok(UserMutableFields { - device_key, + client_key, blockchain_public_key, blockchain_name, used_bytes, @@ -438,7 +444,7 @@ fn process_create_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args: require!(it.next().is_none(), ShineUsersError::InvalidInstruction); require!(signer.is_signer, ShineUsersError::InvalidSigner); - require_keys_eq!(*signer.key, args.fields.device_key, ShineUsersError::InvalidSigner); + require_keys_eq!(*signer.key, args.fields.client_key, ShineUsersError::InvalidSigner); require_keys_eq!(*system_program_ai.key, system_program::id(), ShineUsersError::InvalidSystemProgram); validate_login(&args.login)?; @@ -463,8 +469,9 @@ fn process_create_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args: record_number: 0, prev_record_hash: ZERO_HASH, login: args.login, + recovery_key: args.recovery_key, root_key: args.root_key, - device_key: args.fields.device_key, + client_key: args.fields.client_key, blockchain: BlockchainRecord { blockchain_type: BLOCKCHAIN_TYPE_MAIN_USER, blockchain_name: args.fields.blockchain_name, @@ -520,7 +527,7 @@ fn process_update_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args: require!(it.next().is_none(), ShineUsersError::InvalidInstruction); require!(signer.is_signer, ShineUsersError::InvalidSigner); - require_keys_eq!(*signer.key, args.fields.device_key, ShineUsersError::InvalidSigner); + require_keys_eq!(*signer.key, args.fields.client_key, ShineUsersError::InvalidSigner); require_keys_eq!(*system_program_ai.key, system_program::id(), ShineUsersError::InvalidSystemProgram); validate_login(&args.login)?; @@ -537,7 +544,9 @@ fn process_update_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args: let old_record = deserialize_record_from_pda(&read_pda_all(user_pda)?)?; require!(old_record.login == args.login, ShineUsersError::ImmutableFieldChanged); require!(old_record.created_at_ms == args.created_at_ms, ShineUsersError::ImmutableFieldChanged); + require_keys_eq!(old_record.recovery_key, args.recovery_key, ShineUsersError::ImmutableFieldChanged); require_keys_eq!(old_record.root_key, args.root_key, ShineUsersError::ImmutableFieldChanged); + require_keys_eq!(old_record.client_key, args.fields.client_key, ShineUsersError::ImmutableFieldChanged); require!(args.version == old_record.record_number.saturating_add(1), ShineUsersError::InvalidVersion); require!(hash_unsigned_record(&old_record)? == args.prev_hash, ShineUsersError::InvalidPrevHash); require!(args.fields.blockchain_name == old_record.blockchain.blockchain_name, ShineUsersError::ImmutableFieldChanged); @@ -579,8 +588,9 @@ fn build_update_record(old_record: &UserRecord, args: &UpdateUserPdaArgs, new_ba record_number: args.version, prev_record_hash: args.prev_hash, login: old_record.login.clone(), + recovery_key: old_record.recovery_key, root_key: old_record.root_key, - device_key: args.fields.device_key, + client_key: args.fields.client_key, blockchain: BlockchainRecord { blockchain_type: old_record.blockchain.blockchain_type, blockchain_name: args.fields.blockchain_name.clone(), @@ -672,8 +682,9 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result { let login = read_len_prefixed_string_from(useful, &mut cursor)?; let blocks_count = read_u8_from(useful, &mut cursor)? as usize; + let mut recovery_key = None; let mut root_key = None; - let mut device_key = None; + let mut client_key = None; let mut blockchain = None; let mut is_server = false; let mut address_format_type = 0u8; @@ -690,13 +701,17 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result { let block_version = read_u8_from(useful, &mut cursor)?; require!(block_version == BLOCK_VERSION_0, ShineUsersError::InvalidRecordFormat); match block_type { + BLOCK_TYPE_RECOVERY_KEY => { + require!(recovery_key.is_none(), ShineUsersError::InvalidRecordData); + recovery_key = Some(Pubkey::new_from_array(read_fixed_32_from(useful, &mut cursor)?)); + } BLOCK_TYPE_ROOT_KEY => { require!(root_key.is_none(), ShineUsersError::InvalidRecordData); root_key = Some(Pubkey::new_from_array(read_fixed_32_from(useful, &mut cursor)?)); } - BLOCK_TYPE_DEVICE_KEY => { - require!(device_key.is_none(), ShineUsersError::InvalidRecordData); - device_key = Some(Pubkey::new_from_array(read_fixed_32_from(useful, &mut cursor)?)); + BLOCK_TYPE_CLIENT_KEY => { + require!(client_key.is_none(), ShineUsersError::InvalidRecordData); + client_key = Some(Pubkey::new_from_array(read_fixed_32_from(useful, &mut cursor)?)); } BLOCK_TYPE_BLOCKCHAIN_REGISTRY => { require!(blockchain.is_none(), ShineUsersError::InvalidRecordData); @@ -743,8 +758,9 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result { record_number, prev_record_hash, login, + recovery_key: recovery_key.ok_or(ProgramError::from(ShineUsersError::InvalidRecordData))?, root_key: root_key.ok_or(ProgramError::from(ShineUsersError::InvalidRecordData))?, - device_key: device_key.ok_or(ProgramError::from(ShineUsersError::InvalidRecordData))?, + client_key: client_key.ok_or(ProgramError::from(ShineUsersError::InvalidRecordData))?, blockchain: blockchain.ok_or(ProgramError::from(ShineUsersError::InvalidRecordData))?, is_server, address_format_type, @@ -808,10 +824,11 @@ fn serialize_unsigned_record(record: &UserRecord) -> Result, ProgramErro out.push(login_bytes.len() as u8); out.extend_from_slice(login_bytes); - let blocks_count = if record.is_server { 7 } else { 6 }; + let blocks_count = if record.is_server { 8 } else { 7 }; out.push(blocks_count); + write_recovery_key_block(&mut out, record); write_root_key_block(&mut out, record); - write_device_key_block(&mut out, record); + write_client_key_block(&mut out, record); write_blockchain_registry_block(&mut out, &record.blockchain)?; if record.is_server { write_server_profile_block(&mut out, record)?; } write_access_servers_block(&mut out, record)?; @@ -840,8 +857,9 @@ fn hash_unsigned_record(record: &UserRecord) -> Result<[u8; 32], ProgramError> { Ok(out) } +fn write_recovery_key_block(out: &mut Vec, record: &UserRecord) { out.push(BLOCK_TYPE_RECOVERY_KEY); out.push(BLOCK_VERSION_0); out.extend_from_slice(record.recovery_key.as_ref()); } fn write_root_key_block(out: &mut Vec, record: &UserRecord) { out.push(BLOCK_TYPE_ROOT_KEY); out.push(BLOCK_VERSION_0); out.extend_from_slice(record.root_key.as_ref()); } -fn write_device_key_block(out: &mut Vec, record: &UserRecord) { out.push(BLOCK_TYPE_DEVICE_KEY); out.push(BLOCK_VERSION_0); out.extend_from_slice(record.device_key.as_ref()); } +fn write_client_key_block(out: &mut Vec, record: &UserRecord) { out.push(BLOCK_TYPE_CLIENT_KEY); out.push(BLOCK_VERSION_0); out.extend_from_slice(record.client_key.as_ref()); } fn write_blockchain_registry_block(out: &mut Vec, blockchain: &BlockchainRecord) -> Result<(), ProgramError> { out.push(BLOCK_TYPE_BLOCKCHAIN_REGISTRY); out.push(BLOCK_VERSION_0); out.push(1); write_blockchain_record(out, blockchain) } fn write_blockchain_record(out: &mut Vec, blockchain: &BlockchainRecord) -> Result<(), ProgramError> { out.push(blockchain.blockchain_type); diff --git a/shine-solana/shine/tests/shine.ts b/shine-solana/shine/tests/shine.ts index 4b3c6bc..7ff87b3 100644 --- a/shine-solana/shine/tests/shine.ts +++ b/shine-solana/shine/tests/shine.ts @@ -43,7 +43,7 @@ type SessionRecord = { }; type MutableFields = { - deviceKey: PublicKey; + clientKey: PublicKey; blockchainPublicKey: PublicKey; blockchainName: string; usedBytes: bigint; @@ -69,7 +69,7 @@ type UnsignedRecord = { prevRecordHash: Buffer; login: string; rootKey: PublicKey; - deviceKey: PublicKey; + clientKey: PublicKey; blockchain: { blockchainType: number; blockchainName: string; @@ -126,7 +126,7 @@ function serializeUnsignedRecord(r: UnsignedRecord): Buffer { out.push(Buffer.from([BLOCK_TYPE_ROOT_KEY, BLOCK_VERSION_0])); out.push(r.rootKey.toBuffer()); out.push(Buffer.from([BLOCK_TYPE_DEVICE_KEY, BLOCK_VERSION_0])); - out.push(r.deviceKey.toBuffer()); + out.push(r.clientKey.toBuffer()); out.push(Buffer.from([BLOCK_TYPE_BLOCKCHAIN_REGISTRY, BLOCK_VERSION_0, 1])); out.push(Buffer.from([r.blockchain.blockchainType])); out.push(strBytes(r.blockchain.blockchainName)); @@ -222,11 +222,11 @@ describe("shine_users e2e", () => { const root = anchor.web3.Keypair.generate(); const blockchain = anchor.web3.Keypair.generate(); - const deviceKey = anchor.web3.Keypair.generate().publicKey; + const clientKey = anchor.web3.Keypair.generate().publicKey; const blockchainName = `${login}-001`; const createFields: MutableFields = { - deviceKey, + clientKey, blockchainPublicKey: blockchain.publicKey, blockchainName, usedBytes: 0n, @@ -256,7 +256,7 @@ describe("shine_users e2e", () => { prevRecordHash: ZERO_HASH, login, rootKey: root.publicKey, - deviceKey, + clientKey, blockchain: { blockchainType: BLOCKCHAIN_TYPE_MAIN_USER, blockchainName, @@ -301,7 +301,7 @@ describe("shine_users e2e", () => { createdAtMs: new anchor.BN(createdAtMs.toString()), additionalLimit: new anchor.BN(additionalLimitCreate.toString()), fields: { - deviceKey: createFields.deviceKey, + clientKey: createFields.clientKey, blockchainPublicKey: createFields.blockchainPublicKey, blockchainName: createFields.blockchainName, usedBytes: new anchor.BN(createFields.usedBytes.toString()), @@ -343,9 +343,9 @@ describe("shine_users e2e", () => { const additionalLimitUpdate = 30_000n; expect(additionalLimitUpdate % LIMIT_STEP).eq(0n); - const updatedDeviceKey = anchor.web3.Keypair.generate().publicKey; + const updatedClientKey = anchor.web3.Keypair.generate().publicKey; const updateFields: MutableFields = { - deviceKey: updatedDeviceKey, + clientKey: updatedClientKey, blockchainPublicKey: blockchain.publicKey, blockchainName, usedBytes: 512n, @@ -371,7 +371,7 @@ describe("shine_users e2e", () => { prevRecordHash: sha256(createUnsigned), login, rootKey: root.publicKey, - deviceKey: updatedDeviceKey, + clientKey: updatedClientKey, blockchain: { blockchainType: BLOCKCHAIN_TYPE_MAIN_USER, blockchainName, @@ -419,7 +419,7 @@ describe("shine_users e2e", () => { prevHash: sha256(createUnsigned), additionalLimit: new anchor.BN(additionalLimitUpdate.toString()), fields: { - deviceKey: updateFields.deviceKey, + clientKey: updateFields.clientKey, blockchainPublicKey: updateFields.blockchainPublicKey, blockchainName: updateFields.blockchainName, usedBytes: new anchor.BN(updateFields.usedBytes.toString()),