diff --git a/Dev_Docs/API/05_Technical_Requests_API.md b/Dev_Docs/API/05_Technical_Requests_API.md index 8b3198b..9c3d043 100644 --- a/Dev_Docs/API/05_Technical_Requests_API.md +++ b/Dev_Docs/API/05_Technical_Requests_API.md @@ -2,12 +2,13 @@ Этот файл описывает технические WebSocket-запросы, которые нужны для служебной работы клиента с сервером. Часть операций доступна без авторизации, часть требует успешной авторизованной сессии. -Сейчас здесь восемь методов: +Сейчас здесь девять методов: - `Ping` — keep-alive запрос для поддержания живого WebSocket-соединения; - `GetServerInfo` — запрос базовой публичной информации о сервере для выбора узла в децентрализованной сети; - `ListBlockchainHeads` — краткая сводка по всем локальным блокчейнам сервера для межсерверной синхронизации; - `GetSyncUserProfile` — межсерверный профиль пользователя для создания локальной цепочки без Solana RPC; +- `SendSignal` — общий межсессионный технический сигнал в одну конкретную сессию или сразу во все активные сессии пользователя; - `GetCallIceConfig` — выдача STUN/TURN конфигурации для звонков; - `ClientErrorLog` — отправка клиентской ошибки в серверный лог; - `ClientDebugLog` — отправка клиентского debug-события в серверный буфер; @@ -19,6 +20,7 @@ - `GetServerInfo` нужен до авторизации и до работы с данными, чтобы клиент понял, что сервер доступен, и показал пользователю краткую карточку этого узла. - `ListBlockchainHeads` нужен для сервер-сервер сверки: партнёр получает список heads по всем цепочкам, сравнивает его со своим состоянием и затем добирает недостающие блоки по диапазону. - `GetSyncUserProfile` нужен для server-to-server режима, когда принимающий сервер хочет создать у себя локальные `solana_users + blockchain_state` без прямого обращения в Solana. Это используется как временный обход ограничений внешнего Solana RPC. +- `SendSignal` нужен для доверенных межсессионных команд одного пользователя. Первое практическое применение — `remote AddBlock via homeserver session`, но формат задуман как общий transport на вырост. Ниже сначала описаны назначение методов, затем точные форматы запросов и ответов. @@ -282,7 +284,134 @@ --- -## 5. `GetCallIceConfig` +## 5. `SendSignal` + +Доступно только после успешной авторизации. + +### Назначение + +Общий межсессионный технический сигнал. + +Этот метод нужен для случаев, когда одна активная сессия пользователя должна быстро передать служебную команду другой сессии того же пользователя или сразу всем его активным сессиям. + +Первый целевой сценарий: + +- `remote AddBlock via homeserver session` + +То есть телефон без локального `blockchain.key` может: + +- подготовить unsigned preimage блока; +- подписать сам `SendSignal` своим `session key`; +- дополнительно подписать его `client key`, чтобы homeserver/ESP32 точно видел, что запрос пришёл от доверенного клиента этого же логина; +- отправить запрос в выбранную `homeserver`-сессию; +- получить от неё ответ после настоящего `AddBlock`. + +### Режимы доставки + +- `targetMode = "single_session"` — доставка в одну конкретную `targetSessionId`. +- `targetMode = "all_sessions"` — доставка во все активные сессии указанного логина. + +### Важное правило подписи + +Сам `SendSignal` не подписывает поле `data` отдельной вложенной подписью. Вместо этого сервер проверяет подписи по общему preimage сигнала, в который входит: + +- `fromLogin` +- `fromSessionId` +- `toLogin` +- `targetMode` +- `targetSessionId` +- `signalType` +- `signalRequestId` +- `timeMs` +- `sha256(data)` + +Поддерживаются две подписи: + +- `sessionSignatureB64` — обязательная подпись текущей авторизованной `session key`; +- `clientSignatureB64` — необязательная подпись `client key`. + +Для сценария `remote AddBlock via homeserver` текущая договорённость такая: + +- запрос должен идти только своему же логину; +- запрос должен быть подписан и `session key`, и `client key`; +- в будущем для отдельных wallet-сценариев `clientSignatureB64` может быть пустой. + +### Запрос в одну сессию + +```json +{ + "op": "SendSignal", + "requestId": "ws-req-001", + "payload": { + "toLogin": "alice", + "targetMode": "single_session", + "targetSessionId": "sess-hs-001", + "signalType": "remote_addblock_request", + "signalRequestId": "remote-addblock-001", + "data": "{\"operation\":\"remote_addblock_request\",\"login\":\"alice\",\"blockchainName\":\"alice_main\",\"blockNumber\":152,\"prevBlockHash\":\"abc...\",\"blockPreimageB64\":\"...\"}", + "timeMs": 1774700000123, + "sessionSignatureB64": "BASE64_64", + "clientSignatureB64": "BASE64_64" + } +} +``` + +### Успешный ответ + +```json +{ + "op": "SendSignal", + "requestId": "ws-req-001", + "status": 200, + "ok": true, + "payload": { + "deliveredCount": 1, + "deliveredSessionIds": ["sess-hs-001"] + } +} +``` + +### Событие на принимающей стороне + +```json +{ + "op": "IncomingSignal", + "eventId": "evt-001", + "payload": { + "fromLogin": "alice", + "fromSessionId": "sess-phone-001", + "toLogin": "alice", + "targetMode": "single_session", + "targetSessionId": "sess-hs-001", + "signalType": "remote_addblock_request", + "signalRequestId": "remote-addblock-001", + "data": "{\"operation\":\"remote_addblock_request\",\"login\":\"alice\",\"blockchainName\":\"alice_main\",\"blockNumber\":152,\"prevBlockHash\":\"abc...\",\"blockPreimageB64\":\"...\"}", + "timeMs": 1774700000123, + "sessionSignatureB64": "BASE64_64", + "clientSignatureB64": "BASE64_64", + "dataSha256B64": "BASE64_32" + } +} +``` + +### Специфические коды ошибок `SendSignal` + +- `422 / NOT_AUTHENTICATED` — требуется авторизация. +- `400 / BAD_FIELDS` — не хватает обязательных полей или нарушено правило `single_session/all_sessions`. +- `400 / BAD_TARGET_MODE` — передан неизвестный `targetMode`. +- `400 / TIME_SKEW` — `timeMs` отличается от серверного более чем на 30 секунд. +- `500 / NO_CLIENT_KEY` — для текущего пользователя не найден `client key`. +- `404 / USER_NOT_FOUND` — логин адресата не найден. +- `400 / BAD_DATA` — сервер не смог обработать `data`. +- `400 / BAD_SESSION_SIGNATURE` — некорректная подпись `session key`. +- `400 / BAD_CLIENT_SIGNATURE` — некорректная подпись `client key`. +- `404 / SESSION_NOT_FOUND` — при `single_session` целевая сессия не найдена или не онлайн. +- `404 / NO_TARGET_SESSIONS` — при `all_sessions` у пользователя сейчас нет активных онлайн-сессий. +- `404 / DELIVERY_FAILED` — сервер не смог отправить событие ни в одну из целевых сессий. + +--- + +## 6. `GetCallIceConfig` Доступно только после успешной авторизации. @@ -332,7 +461,7 @@ --- -## 6. `ClientErrorLog` +## 7. `ClientErrorLog` ### Запрос @@ -379,7 +508,7 @@ --- -## 7. `ClientDebugLog` +## 8. `ClientDebugLog` ### Запрос @@ -417,7 +546,7 @@ --- -## 8. `CallDeliveryReport` +## 9. `CallDeliveryReport` ### Запрос @@ -453,29 +582,10 @@ --- -## 7. Короткое резюме +## 10. Короткое резюме - `Ping` нужен для keep-alive и проверки, что WebSocket-соединение живо. - `GetServerInfo` нужен для выбора сервера в сети и показа публичной информации об узле. +- `SendSignal` нужен для доверенных межсессионных сигналов одного пользователя, включая `remote AddBlock via homeserver session`. - `GetCallIceConfig` нужен для WebRTC-звонков и требует авторизации. - `ClientErrorLog`, `ClientDebugLog`, `CallDeliveryReport` используются для диагностики клиента и звонков. - - -## 8. Прямое техническое сообщение в конкретную сессию - -На текущий момент в публичном JSON API этого документа **нет отдельного RPC** для отправки произвольного технического сообщения в конкретную сессию пользователя (по `sessionId`). - -Что уже есть в системе: - -- сервер хранит `sessionId` активной сессии; -- есть `ListSessions`, чтобы клиент получил список sessionId своего пользователя; -- у сервера есть внутренний реестр активных WS-подключений по `sessionId`. - -Чего не хватает для полноценной фичи «direct tech message by sessionId»: - -1. отдельная API-операция (например, `SendSessionTechMessage`); -2. правило авторизации (кто имеет право писать в чужую/свою сессию); -3. унифицированный формат payload и события доставки; -4. коды ошибок (`SESSION_OFFLINE`, `SESSION_NOT_FOUND`, `FORBIDDEN` и т.п.). - -Итог: как инфраструктурная база это почти готово, но нужен отдельный RPC-слой и политика доступа. diff --git a/Dev_Docs/API/09_Operations_Index.md b/Dev_Docs/API/09_Operations_Index.md index 1800691..9fce01e 100644 --- a/Dev_Docs/API/09_Operations_Index.md +++ b/Dev_Docs/API/09_Operations_Index.md @@ -37,6 +37,7 @@ | `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере | | `ListBlockchainHeads` | `05_Technical_Requests_API.md` | список heads всех локальных блокчейнов | | `GetSyncUserProfile` | `05_Technical_Requests_API.md` | межсерверный профиль пользователя для синхронизации | +| `SendSignal` | `05_Technical_Requests_API.md` | общий межсессионный технический сигнал в одну или все сессии пользователя | | `GetCallIceConfig` | `05_Technical_Requests_API.md` | STUN/TURN конфигурация звонков | | `ClientErrorLog` | `05_Technical_Requests_API.md` | логирование клиентской ошибки | | `ClientDebugLog` | `05_Technical_Requests_API.md` | клиентский debug-лог | diff --git a/Dev_Docs/Pending_Features/2026-06-28_1330_remote_addblock_через_homeserver.md b/Dev_Docs/Pending_Features/2026-06-28_1330_remote_addblock_через_homeserver.md new file mode 100644 index 0000000..1e08963 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-28_1330_remote_addblock_через_homeserver.md @@ -0,0 +1,33 @@ +# Remote AddBlock через homeserver + +- Статус: `pending` +- Дата: `2026-06-28 13:30` + +## Что сделано + +Добавлен общий серверный API `SendSignal` и первый сценарий его использования: + +- клиент без локального `blockchain.key` выбирает `homeserver`-сессию (`sessionType = 100`); +- клиент отправляет в неё `remote_addblock_request` через `SendSignal`; +- запрос подписывается `session key` и `client key`; +- ESP32/homeserver автоматически подписывает настоящий `AddBlock` своим `blockchain key` и сам отправляет его на сервер; +- результат возвращается назад сигналом `remote_addblock_result`. + +## Что проверить вручную + +1. На клиенте без локального `blockchain.key` открыть настройки и выбрать активную `homeserver`-сессию для remote AddBlock. +2. Выполнить любое действие UI, которое приводит к `AddBlock`. +3. Убедиться, что клиент не падает в ошибку отсутствия `blockchain.key`, а отправляет `SendSignal`. +4. Убедиться, что ESP32/homeserver получает `IncomingSignal` с `remote_addblock_request`. +5. Убедиться, что homeserver отправляет обычный `AddBlock` на сервер. +6. Убедиться, что клиент получает `remote_addblock_result` и завершает исходную операцию как успешную. +7. Проверить негативный сценарий: + - homeserver-сессия не выбрана; + - homeserver офлайн; + - homeserver возвращает ошибку `AddBlock`. + +## Ожидаемый результат + +- При наличии локального `blockchain.key` клиент продолжает работать по старому локальному пути. +- При отсутствии локального `blockchain.key` и выбранной `homeserver`-сессии `AddBlock` проходит через удалённую подпись и удалённую отправку. +- В ошибочных сценариях пользователь получает понятную ошибку без скрытого fallback. diff --git a/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md b/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md index 5333464..64b77db 100644 --- a/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md +++ b/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md @@ -1,6 +1,6 @@ # ESP Pairing и режимы подключения -Этот документ фиксирует текущие и новые режимы входа/подключения в SHiNE без клиентской UI-реализации. Он нужен как отдельная точка входа по сценариям подключения, чтобы не смешивать обычную авторизацию и серверный pairing через доверенное уже авторизованное устройство пользователя. +Этот документ фиксирует актуальные режимы входа/подключения в SHiNE. Он нужен как отдельная точка входа по сценариям подключения, чтобы не смешивать обычную авторизацию и серверный pairing через доверенное уже авторизованное устройство пользователя. ## 1. Текущие режимы @@ -30,7 +30,7 @@ - соединение снова входит в существующую сессию; - этот поток тоже остаётся без изменений. -## 2. Новый режим: добавление сессии через доверенное устройство пользователя +## 2. Добавление сессии через доверенное устройство пользователя Новый поток не заменяет обычный логин, а живёт рядом с ним. @@ -41,7 +41,7 @@ - реальное доверие даёт любая уже онлайн доверенная сессия пользователя; - сервер не выдаёт приватные ключи сам от себя. -Поток версии `v1`: +Текущий поток: 1. Любая доверенная сессия пользователя создаёт на сервере pairing-настройку: `UpsertEspPairingSettings` @@ -65,7 +65,7 @@ - уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены; - хранит переданный `encryptedPayload` как непрозрачную строку и не анализирует его содержимое. -## 4. Чего сервер в этой версии не делает +## 4. Чего сервер в этом режиме не делает - не передаёт приватный `clientKey`; - не расшифровывает `encryptedPayload`; @@ -73,7 +73,7 @@ - не делает клиентский UI; - не навязывает конкретную схему `Ed25519 -> X25519` в коде сервера. -Это намеренно: серверная версия `v1` подготавливает безопасный каркас маршрутизации и состояния, а настоящая E2E-логика упаковки ключей будет жить на клиентах и ESP-устройствах. +Это намеренно: сервер остаётся безопасным каркасом маршрутизации и состояния, а E2E-логика упаковки ключей живёт на клиентах и ESP-устройствах. ## 5. Роли и ограничения diff --git a/Dev_Docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md b/Dev_Docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md index 1b0c431..5421455 100644 --- a/Dev_Docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md +++ b/Dev_Docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md @@ -1,6 +1,6 @@ # Формат взаимодействия внешнего кошелька и ESP32 -Этот документ фиксирует первый этап формата взаимодействия между внешним браузерным wallet-расширением SHiNE и устройством `ESP32-S3-Touch-AMOLED-2.16`. +Этот документ фиксирует актуальный формат взаимодействия между внешним браузерным wallet-расширением SHiNE и устройством `ESP32-S3-Touch-AMOLED-2.16`. Документ описывает: @@ -39,13 +39,13 @@ ESP32 возвращает: ## 2. Транспорт и маршрут -Первая версия формата использует уже существующую `wallet-session` браузерного расширения. +Текущий формат использует уже существующую `wallet-session` браузерного расширения. Схема маршрута: `browser extension -> SHiNE server -> homeserver session on ESP32 -> SHiNE server -> browser extension` -В первой версии: +В текущем формате: - отдельная цифровая подпись payload не добавляется; - отдельное E2E-шифрование для wallet RPC не добавляется; @@ -90,7 +90,7 @@ ESP32 возвращает: - `customName`; - `targetSessionName`. -Они намеренно не входят в первую версию этого запроса. +Они намеренно не входят в текущий формат этого запроса. ## 4. Формат ответа @@ -121,9 +121,9 @@ ESP32 возвращает: - `wallet.publicKeyBase58` — публичный ключ активного кошелька в `Base58`. - `timeMs` — время формирования ответа на стороне ESP32 в миллисекундах. -## 6. Ошибки первой версии +## 6. Ошибки текущего формата -Минимальный формат ошибки в первой версии допускается таким: +Минимальный формат ошибки допускается таким: ```json { @@ -136,7 +136,7 @@ ESP32 возвращает: } ``` -Рекомендуемые коды ошибок первой версии: +Рекомендуемые коды ошибок: - `wallet_unavailable` — на устройстве нельзя получить текущий кошелёк; - `secret_not_configured` — на устройстве ещё нет корректно сохранённого секрета; @@ -158,7 +158,7 @@ ESP32 возвращает: - если `wallet.type = client.key`, то `publicKeyBase58` должен совпасть с `clientKey`, прочитанным из PDA; - если `wallet.type = root.key`, то `publicKeyBase58` должен совпасть с `rootKey`, прочитанным из PDA; -- если `wallet.type = custom`, такой проверки по PDA в первой версии нет. +- если `wallet.type = custom`, такой проверки по PDA пока нет. При несовпадении для `client.key` или `root.key` расширение должно показать пользователю предупреждение, что возвращённый ключ не совпал с ожидаемым ключом из PDA. @@ -308,4 +308,4 @@ ESP32: - выбор типа кошелька делается только на самом ESP32; - отдельная цифровая подпись ответа пока не используется; - отдельное E2E-шифрование wallet RPC пока не используется; -- `custom`-кошельки в первой версии не сверяются с PDA. +- `custom`-кошельки пока не сверяются с PDA. 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 0d7482a..68fe39a 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 @@ -285,6 +285,17 @@ struct ActiveWalletSignRequest { Screen returnScreen = SCREEN_HOME; }; +struct PendingRemoteAddBlockRequest { + String fromLogin; + String fromSessionId; + String signalRequestId; + String signalType; + String data; + String sessionSignatureB64; + String clientSignatureB64; + uint64_t timeMs = 0; +}; + static lv_disp_draw_buf_t gDrawBuf; static lv_color_t *gBuf1 = nullptr; static lv_color_t *gBuf2 = nullptr; @@ -415,6 +426,7 @@ static String gCustomWalletName; static String gCustomWalletPubB58; static String gCustomWalletPrivB58; static std::vector gPendingWalletRpcRequests; +static std::vector gPendingRemoteAddBlockRequests; static const int kWalletRpcSignalTypeRequest = 9100; static const int kWalletRpcSignalTypeResponse = 9101; static ActiveWalletSignRequest gActiveWalletSignRequest; @@ -477,7 +489,9 @@ static void refreshSelectedWalletBalanceState(); static bool loadSelectedWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut); static bool loadBalanceLamportsForAddress(const String &address, uint64_t &lamportsOut, String &messageOut); static bool queueIncomingWalletRpcRequest(const String &frame); +static bool queueIncomingRemoteAddBlockRequest(const String &frame); static void processPendingWalletRpcRequests(); +static void processPendingRemoteAddBlockRequests(); static void pumpShineIncomingFrames(uint32_t maxFrames = 3); static String loginDisplayValue(); static String homeserverDisplayValue(); @@ -617,6 +631,22 @@ static bool sendWalletRpcResponse(const String &toLogin, const String &targetSessionId, const String &callId, const String &responseData); +static bool sendSignalResponse(const String &toLogin, + const String &targetSessionId, + const String &signalType, + const String &signalRequestId, + const String &responseData); +static bool buildSendSignalSignatures(const String &toLogin, + const String &targetMode, + const String &targetSessionId, + const String &signalType, + const String &signalRequestId, + const String &data, + uint64_t timeMs, + bool includeClientSignature, + String &sessionSignatureB64Out, + String &clientSignatureB64Out, + String &errorOut); static void queueWalletSignRequest(const PendingWalletRpcRequest &item, const String &requestId, const String &publicKeyBase58, @@ -2695,6 +2725,126 @@ static bool sendWalletRpcResponse(const String &toLogin, return shineWsRequest(gShineWs, "CallSignalToSession", req, response, SHINE_RPC_TIMEOUT_MS); } +static bool buildSendSignalSignatures(const String &toLogin, + const String &targetMode, + const String &targetSessionId, + const String &signalType, + const String &signalRequestId, + const String &data, + uint64_t timeMs, + bool includeClientSignature, + String &sessionSignatureB64Out, + String &clientSignatureB64Out, + String &errorOut) { + sessionSignatureB64Out = ""; + clientSignatureB64Out = ""; + errorOut = ""; + + if (gLoginValue.isEmpty() || gShineSessionId.isEmpty()) { + errorOut = "session_context_missing"; + return false; + } + + uint8_t dataHash32[32] = {}; + sha256calc(reinterpret_cast(data.c_str()), data.length(), dataHash32); + String dataSha256B64 = bytesToBase64String(dataHash32, sizeof(dataHash32)); + + uint8_t subSeed[32] = {}; + uint8_t subPub[32] = {}; + uint8_t subSec[64] = {}; + if (!deriveSeedKeypairFromBase58(gHomeserverPrivB58, subSeed, subPub, subSec)) { + errorOut = "homeserver_session_key_unavailable"; + return false; + } + + String sessionPreimage = String("SEND_SIGNAL_SESSION:") + + gLoginValue + ":" + + gShineSessionId + ":" + + toLogin + ":" + + targetMode + ":" + + targetSessionId + ":" + + signalType + ":" + + signalRequestId + ":" + + String((unsigned long long)timeMs) + ":" + + dataSha256B64; + uint8_t sessionSignature[64] = {}; + std::vector sessionMessage(reinterpret_cast(sessionPreimage.c_str()), + reinterpret_cast(sessionPreimage.c_str()) + sessionPreimage.length()); + if (!signMessageEd25519(sessionMessage, subSec, sessionSignature)) { + errorOut = "session_signal_sign_failed"; + return false; + } + sessionSignatureB64Out = bytesToBase64String(sessionSignature, sizeof(sessionSignature)); + + if (!includeClientSignature) { + return true; + } + + uint8_t clientSeed[32] = {}; + uint8_t clientPub[32] = {}; + uint8_t clientSec[64] = {}; + if (!deriveSeedKeypairFromBase58(gDevicePrivB58, clientSeed, clientPub, clientSec)) { + errorOut = "client_key_unavailable"; + return false; + } + + String clientPreimage = String("SEND_SIGNAL_CLIENT:") + + gLoginValue + ":" + + gShineSessionId + ":" + + toLogin + ":" + + targetMode + ":" + + targetSessionId + ":" + + signalType + ":" + + signalRequestId + ":" + + String((unsigned long long)timeMs) + ":" + + dataSha256B64; + uint8_t clientSignature[64] = {}; + std::vector clientMessage(reinterpret_cast(clientPreimage.c_str()), + reinterpret_cast(clientPreimage.c_str()) + clientPreimage.length()); + if (!signMessageEd25519(clientMessage, clientSec, clientSignature)) { + errorOut = "client_signal_sign_failed"; + return false; + } + clientSignatureB64Out = bytesToBase64String(clientSignature, sizeof(clientSignature)); + return true; +} + +static bool sendSignalResponse(const String &toLogin, + const String &targetSessionId, + const String &signalType, + const String &signalRequestId, + const String &responseData) { + String response; + uint64_t timeMs = shineNowMs(); + String sessionSignatureB64; + String clientSignatureB64; + String error; + if (!buildSendSignalSignatures(toLogin, + "single_session", + targetSessionId, + signalType, + signalRequestId, + responseData, + timeMs, + false, + sessionSignatureB64, + clientSignatureB64, + error)) { + return false; + } + + String req = String("{\"toLogin\":\"") + jsonEscape(toLogin) + + "\",\"targetMode\":\"single_session\"" + + ",\"targetSessionId\":\"" + jsonEscape(targetSessionId) + + "\",\"signalType\":\"" + jsonEscape(signalType) + + "\",\"signalRequestId\":\"" + jsonEscape(signalRequestId) + + "\",\"data\":\"" + jsonEscape(responseData) + + "\",\"timeMs\":" + String((unsigned long long)timeMs) + + ",\"sessionSignatureB64\":\"" + jsonEscape(sessionSignatureB64) + + "\",\"clientSignatureB64\":\"" + jsonEscape(clientSignatureB64) + "\"}"; + return shineWsRequest(gShineWs, "SendSignal", req, response, SHINE_RPC_TIMEOUT_MS); +} + static void queueWalletSignRequest(const PendingWalletRpcRequest &item, const String &requestId, const String &publicKeyBase58, @@ -4075,6 +4225,7 @@ static bool shineWsRequest(SimpleWebSocketClient &ws, const String &op, const St return true; } queueIncomingWalletRpcRequest(frame); + queueIncomingRemoteAddBlockRequest(frame); } return false; } @@ -4121,6 +4272,50 @@ static bool queueIncomingWalletRpcRequest(const String &frame) { return true; } +static bool queueIncomingRemoteAddBlockRequest(const String &frame) { + if (frame.indexOf("\"op\":\"IncomingSignal\"") < 0 || frame.indexOf("\"event\":true") < 0) { + return false; + } + int payloadPos = frame.indexOf("\"payload\":"); + if (payloadPos < 0) { + return false; + } + int objectStart = frame.indexOf('{', payloadPos); + if (objectStart < 0) { + return false; + } + int objectEnd = -1; + String payloadJson; + if (!extractJsonObjectAt(frame, objectStart, objectEnd, payloadJson)) { + return false; + } + + String signalType; + if (!jsonStringField(payloadJson, "signalType", signalType)) { + return false; + } + if (signalType != "remote_addblock_request") { + return false; + } + + PendingRemoteAddBlockRequest item; + jsonStringField(payloadJson, "fromLogin", item.fromLogin); + jsonStringField(payloadJson, "fromSessionId", item.fromSessionId); + jsonStringField(payloadJson, "signalRequestId", item.signalRequestId); + jsonStringField(payloadJson, "data", item.data); + jsonStringField(payloadJson, "sessionSignatureB64", item.sessionSignatureB64); + jsonStringField(payloadJson, "clientSignatureB64", item.clientSignatureB64); + uint64_t timeMs = 0; + jsonInt64Field(payloadJson, "timeMs", timeMs); + item.timeMs = timeMs; + item.signalType = signalType; + if (item.fromLogin.isEmpty() || item.fromSessionId.isEmpty() || item.signalRequestId.isEmpty()) { + return false; + } + gPendingRemoteAddBlockRequests.push_back(item); + return true; +} + static void processPendingWalletRpcRequests() { if (gPendingWalletRpcRequests.empty() || !gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) { return; @@ -4180,6 +4375,119 @@ static void processPendingWalletRpcRequests() { } } +static void processPendingRemoteAddBlockRequests() { + if (gPendingRemoteAddBlockRequests.empty() || !gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) { + return; + } + + while (!gPendingRemoteAddBlockRequests.empty()) { + PendingRemoteAddBlockRequest item = gPendingRemoteAddBlockRequests.front(); + gPendingRemoteAddBlockRequests.erase(gPendingRemoteAddBlockRequests.begin()); + + String responseData; + String requestLogin; + String blockchainName; + String prevBlockHash; + String blockPreimageB64; + uint64_t blockNumberU64 = 0; + + if (item.fromLogin != gLoginValue) { + responseData = String("{\"ok\":false,\"error\":\"forbidden_login\",\"errorMessage\":\"Signal login mismatch\",\"requestId\":\"") + + jsonEscape(item.signalRequestId) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + continue; + } + if (item.sessionSignatureB64.isEmpty() || item.clientSignatureB64.isEmpty()) { + responseData = String("{\"ok\":false,\"error\":\"missing_required_signatures\",\"errorMessage\":\"Client and session signatures are required\",\"requestId\":\"") + + jsonEscape(item.signalRequestId) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + continue; + } + + jsonStringField(item.data, "login", requestLogin); + jsonStringField(item.data, "blockchainName", blockchainName); + jsonStringField(item.data, "prevBlockHash", prevBlockHash); + jsonStringField(item.data, "blockPreimageB64", blockPreimageB64); + jsonInt64Field(item.data, "blockNumber", blockNumberU64); + if (requestLogin != gLoginValue || blockchainName.isEmpty() || blockPreimageB64.isEmpty() || prevBlockHash.isEmpty()) { + responseData = String("{\"ok\":false,\"error\":\"bad_request\",\"errorMessage\":\"Missing required AddBlock fields\",\"requestId\":\"") + + jsonEscape(item.signalRequestId) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + continue; + } + + std::vector preimage; + if (!base64DecodeStd(blockPreimageB64, preimage) || preimage.empty()) { + responseData = String("{\"ok\":false,\"error\":\"bad_preimage_base64\",\"errorMessage\":\"Invalid AddBlock preimage base64\",\"requestId\":\"") + + jsonEscape(item.signalRequestId) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + continue; + } + + uint8_t blockchainSeed[32] = {}; + uint8_t blockchainPub[32] = {}; + uint8_t blockchainSec[64] = {}; + if (!deriveSeedKeypairFromBase58(gBlockchainPrivB58, blockchainSeed, blockchainPub, blockchainSec)) { + responseData = String("{\"ok\":false,\"error\":\"blockchain_key_unavailable\",\"errorMessage\":\"Blockchain key is unavailable on homeserver\",\"requestId\":\"") + + jsonEscape(item.signalRequestId) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + continue; + } + + uint8_t hash32[32] = {}; + sha256calc(preimage.data(), preimage.size(), hash32); + uint8_t signature[64] = {}; + if (!signMessageEd25519(std::vector(hash32, hash32 + 32), blockchainSec, signature)) { + responseData = String("{\"ok\":false,\"error\":\"sign_failed\",\"errorMessage\":\"Failed to sign AddBlock hash\",\"requestId\":\"") + + jsonEscape(item.signalRequestId) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + continue; + } + + std::vector fullBlock = preimage; + fullBlock.push_back(0x01); + fullBlock.push_back(0x00); + fullBlock.insert(fullBlock.end(), signature, signature + 64); + String addBlockReq = String("{\"blockchainName\":\"") + jsonEscape(blockchainName) + + "\",\"blockNumber\":" + String((unsigned long long)blockNumberU64) + + ",\"prevBlockHash\":\"" + jsonEscape(prevBlockHash) + + "\",\"blockBytesB64\":\"" + jsonEscape(bytesToBase64String(fullBlock.data(), fullBlock.size())) + "\"}"; + String addBlockResp; + bool addBlockOk = shineWsRequest(gShineWs, "AddBlock", addBlockReq, addBlockResp, SHINE_RPC_TIMEOUT_MS); + uint64_t statusCode = 0; + jsonInt64Field(addBlockResp, "status", statusCode); + String serverLastHash; + String errorCode; + String errorMessage; + uint64_t serverLastNumber = 0; + jsonStringField(addBlockResp, "serverLastGlobalHash", serverLastHash); + jsonStringField(addBlockResp, "code", errorCode); + jsonStringField(addBlockResp, "message", errorMessage); + jsonInt64Field(addBlockResp, "serverLastGlobalNumber", serverLastNumber); + + if (!addBlockOk || statusCode != 200) { + if (errorCode.isEmpty()) { + errorCode = addBlockOk ? "addblock_rejected" : "addblock_request_failed"; + } + if (errorMessage.isEmpty()) { + errorMessage = addBlockOk ? "AddBlock rejected by server" : "AddBlock request failed"; + } + responseData = String("{\"ok\":false,\"error\":\"") + jsonEscape(errorCode) + + "\",\"errorMessage\":\"" + jsonEscape(errorMessage) + + "\",\"requestId\":\"" + jsonEscape(item.signalRequestId) + + "\",\"serverLastGlobalNumber\":" + String((unsigned long long)serverLastNumber) + + ",\"serverLastGlobalHash\":\"" + jsonEscape(serverLastHash) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + continue; + } + + responseData = String("{\"ok\":true,\"requestId\":\"") + jsonEscape(item.signalRequestId) + + "\",\"serverLastGlobalNumber\":" + String((unsigned long long)serverLastNumber) + + ",\"serverLastGlobalHash\":\"" + jsonEscape(serverLastHash) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + } +} + static void pumpShineIncomingFrames(uint32_t maxFrames) { if (!gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) { return; @@ -4193,6 +4501,7 @@ static void pumpShineIncomingFrames(uint32_t maxFrames) { break; } queueIncomingWalletRpcRequest(frame); + queueIncomingRemoteAddBlockRequest(frame); } } @@ -4748,6 +5057,7 @@ static void manageShineConnection() { pumpShineIncomingFrames(); processPendingWalletRpcRequests(); + processPendingRemoteAddBlockRequests(); } static void upsertKnownWifi(const String &ssid, const String &password) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 4d42cbc..46981cc 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -89,6 +89,7 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListConta import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler; +import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler; import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler; import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler; import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler; @@ -97,6 +98,7 @@ import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler; import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request; @@ -196,6 +198,7 @@ public final class JsonHandlerRegistry { Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()), Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()), Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()), + Map.entry("SendSignal", new Net_SendSignal_Handler()), // --- system --- Map.entry("Ping", new Net_Ping_Handler()), @@ -274,6 +277,7 @@ public final class JsonHandlerRegistry { Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class), Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class), Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class), + Map.entry("SendSignal", Net_SendSignal_Request.class), // --- system --- Map.entry("Ping", Net_Ping_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java new file mode 100644 index 0000000..6a751cd --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java @@ -0,0 +1,220 @@ +package server.logic.ws_protocol.JSON.messages; + +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.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.messages.entyties.Net_SendSignal_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Response; +import server.logic.ws_protocol.JSON.push.WsEventSender; +import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.SolanaUsersDAO; +import shine.db.entities.ActiveSessionEntry; +import shine.db.entities.SolanaUserEntry; +import utils.crypto.Ed25519Util; + +import java.security.MessageDigest; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Set; + +public class Net_SendSignal_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_SendSignal_Handler.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String TARGET_MODE_SINGLE = "single_session"; + private static final String TARGET_MODE_ALL = "all_sessions"; + private static final long ALLOWED_SKEW_MS = 30_000L; + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_SendSignal_Request req = (Net_SendSignal_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getActiveSession() == null) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); + } + + String fromLogin = safe(ctx.getLogin()); + String fromSessionId = safe(ctx.getSessionId()); + ActiveSessionEntry activeSession = ctx.getActiveSession(); + + String toRequest = safe(req.getToLogin()); + String targetMode = safe(req.getTargetMode()).toLowerCase(); + String targetSessionId = safe(req.getTargetSessionId()); + String signalType = safe(req.getSignalType()); + String signalRequestId = safe(req.getSignalRequestId()); + String data = req.getData() == null ? "" : req.getData(); + Long timeMs = req.getTimeMs(); + String sessionSignatureB64 = safe(req.getSessionSignatureB64()); + String clientSignatureB64 = safe(req.getClientSignatureB64()); + + if (toRequest.isBlank() || signalType.isBlank() || signalRequestId.isBlank() || timeMs == null || sessionSignatureB64.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/targetMode/signalType/signalRequestId/timeMs/sessionSignatureB64 обязательны"); + } + if (!TARGET_MODE_SINGLE.equals(targetMode) && !TARGET_MODE_ALL.equals(targetMode)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_TARGET_MODE", "Поддерживаются targetMode=single_session или all_sessions"); + } + if (TARGET_MODE_SINGLE.equals(targetMode) && targetSessionId.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Для single_session нужен targetSessionId"); + } + if (TARGET_MODE_ALL.equals(targetMode) && !targetSessionId.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Для all_sessions нельзя передавать targetSessionId"); + } + + long nowMs = System.currentTimeMillis(); + if (Math.abs(nowMs - timeMs) > ALLOWED_SKEW_MS) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "TIME_SKEW", "Время клиента отличается от сервера более чем на 30 секунд"); + } + + SolanaUserEntry senderUser = ctx.getSolanaUser(); + if (senderUser == null || senderUser.getClientKey() == null || senderUser.getClientKey().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, "NO_CLIENT_KEY", "Для пользователя не найден client key"); + } + + SolanaUserEntry targetUser = SolanaUsersDAO.getInstance().getByLogin(toRequest); + if (targetUser == null) { + return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); + } + String toLogin = safe(targetUser.getLogin()); + + String digestB64; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digestB64 = Base64Ws.encode(digest.digest(data.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + log.warn("SendSignal: failed to hash signal data (fromLogin={}, signalType={})", fromLogin, signalType, e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_DATA", "Не удалось обработать data"); + } + + String sessionPreimage = buildSessionPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64); + if (!verifySignature(activeSession.getSessionKey(), sessionPreimage, sessionSignatureB64, "sessionKey")) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_SESSION_SIGNATURE", "Некорректная подпись session key"); + } + + if (!clientSignatureB64.isBlank()) { + String clientPreimage = buildClientPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64); + if (!verifySignature(senderUser.getClientKey(), clientPreimage, clientSignatureB64, "clientKey")) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_CLIENT_SIGNATURE", "Некорректная подпись client key"); + } + } + + List targets = resolveTargets(targetMode, toLogin, targetSessionId); + if (targets.isEmpty()) { + String code = TARGET_MODE_SINGLE.equals(targetMode) ? "SESSION_NOT_FOUND" : "NO_TARGET_SESSIONS"; + String msg = TARGET_MODE_SINGLE.equals(targetMode) ? "Целевая сессия не найдена" : "Нет активных сессий для доставки сигнала"; + return NetExceptionResponseFactory.error(req, 404, code, msg); + } + + List deliveredSessionIds = new ArrayList<>(); + for (ConnectionContext targetCtx : targets) { + String eventId = server.logic.ws_protocol.JSON.utils.NetIdGenerator.eventId("evt"); + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("eventId", eventId); + payload.put("fromLogin", fromLogin); + payload.put("fromSessionId", fromSessionId); + payload.put("toLogin", toLogin); + payload.put("targetMode", targetMode); + payload.put("targetSessionId", safe(targetCtx.getSessionId())); + payload.put("signalType", signalType); + payload.put("signalRequestId", signalRequestId); + payload.put("data", data); + payload.put("timeMs", timeMs); + payload.put("sessionSignatureB64", sessionSignatureB64); + payload.put("clientSignatureB64", clientSignatureB64); + payload.put("dataSha256B64", digestB64); + if (WsEventSender.sendEvent(targetCtx, "IncomingSignal", eventId, payload)) { + deliveredSessionIds.add(safe(targetCtx.getSessionId())); + } + } + + if (deliveredSessionIds.isEmpty()) { + return NetExceptionResponseFactory.error(req, 404, "DELIVERY_FAILED", "Не удалось доставить сигнал ни в одну целевую сессию"); + } + + Net_SendSignal_Response resp = new Net_SendSignal_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setDeliveredCount(deliveredSessionIds.size()); + resp.setDeliveredSessionIds(deliveredSessionIds); + return resp; + } + + private static String buildSessionPreimage(String fromLogin, + String fromSessionId, + String toLogin, + String targetMode, + String targetSessionId, + String signalType, + String signalRequestId, + long timeMs, + String dataSha256B64) { + return "SEND_SIGNAL_SESSION:" + + fromLogin + ":" + + fromSessionId + ":" + + toLogin + ":" + + targetMode + ":" + + targetSessionId + ":" + + signalType + ":" + + signalRequestId + ":" + + timeMs + ":" + + dataSha256B64; + } + + private static String buildClientPreimage(String fromLogin, + String fromSessionId, + String toLogin, + String targetMode, + String targetSessionId, + String signalType, + String signalRequestId, + long timeMs, + String dataSha256B64) { + return "SEND_SIGNAL_CLIENT:" + + fromLogin + ":" + + fromSessionId + ":" + + toLogin + ":" + + targetMode + ":" + + targetSessionId + ":" + + signalType + ":" + + signalRequestId + ":" + + timeMs + ":" + + dataSha256B64; + } + + private static boolean verifySignature(String publicKeyValue, String preimage, String signatureB64, String fieldName) throws Exception { + byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(publicKeyValue, fieldName); + byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); + return Ed25519Util.verify(preimage.getBytes(StandardCharsets.UTF_8), signature64, publicKey32); + } + + private static List resolveTargets(String targetMode, String toLogin, String targetSessionId) { + List targets = new ArrayList<>(); + if (TARGET_MODE_SINGLE.equals(targetMode)) { + ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId); + if (targetCtx != null && toLogin.equalsIgnoreCase(safe(targetCtx.getLogin()))) { + targets.add(targetCtx); + } + return targets; + } + + Set onlineTargets = ActiveConnectionsRegistry.getInstance().getByLogin(toLogin); + onlineTargets.stream() + .filter(item -> item != null && item.getWsSession() != null && item.getWsSession().isOpen()) + .sorted(Comparator.comparing(ConnectionContext::getSessionId, Comparator.nullsLast(String::compareTo))) + .forEach(targets::add); + return targets; + } + + private static String safe(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Request.java new file mode 100644 index 0000000..56c65e1 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Request.java @@ -0,0 +1,87 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_SendSignal_Request extends Net_Request { + private String toLogin; + private String targetMode; + private String targetSessionId; + private String signalType; + private String signalRequestId; + private String data; + private Long timeMs; + private String sessionSignatureB64; + private String clientSignatureB64; + + public String getToLogin() { + return toLogin; + } + + public void setToLogin(String toLogin) { + this.toLogin = toLogin; + } + + public String getTargetMode() { + return targetMode; + } + + public void setTargetMode(String targetMode) { + this.targetMode = targetMode; + } + + public String getTargetSessionId() { + return targetSessionId; + } + + public void setTargetSessionId(String targetSessionId) { + this.targetSessionId = targetSessionId; + } + + public String getSignalType() { + return signalType; + } + + public void setSignalType(String signalType) { + this.signalType = signalType; + } + + public String getSignalRequestId() { + return signalRequestId; + } + + public void setSignalRequestId(String signalRequestId) { + this.signalRequestId = signalRequestId; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } + + public Long getTimeMs() { + return timeMs; + } + + public void setTimeMs(Long timeMs) { + this.timeMs = timeMs; + } + + public String getSessionSignatureB64() { + return sessionSignatureB64; + } + + public void setSessionSignatureB64(String sessionSignatureB64) { + this.sessionSignatureB64 = sessionSignatureB64; + } + + public String getClientSignatureB64() { + return clientSignatureB64; + } + + public void setClientSignatureB64(String clientSignatureB64) { + this.clientSignatureB64 = clientSignatureB64; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Response.java new file mode 100644 index 0000000..53b807d --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Response.java @@ -0,0 +1,27 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +public class Net_SendSignal_Response extends Net_Response { + private int deliveredCount; + private List deliveredSessionIds = new ArrayList<>(); + + public int getDeliveredCount() { + return deliveredCount; + } + + public void setDeliveredCount(int deliveredCount) { + this.deliveredCount = deliveredCount; + } + + public List getDeliveredSessionIds() { + return deliveredSessionIds; + } + + public void setDeliveredSessionIds(List deliveredSessionIds) { + this.deliveredSessionIds = deliveredSessionIds; + } +} diff --git a/TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md b/TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md new file mode 100644 index 0000000..2f695b6 --- /dev/null +++ b/TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md @@ -0,0 +1,29 @@ +# Перенести старые сессионные сигналы на `SendSignal` + +## Контекст + +В проект добавлен новый общий межсессионный transport `SendSignal`. + +Первое текущее применение: + +- `remote AddBlock via homeserver session` + +Старые сценарии пока оставлены на прежнем транспорте, чтобы не ломать уже работающий код. + +## Что перенести позже + +1. Звонковые сигналы, которые сейчас идут через `CallSignalToSession`. +2. Старый wallet/ESP32 обмен, где технические команды всё ещё привязаны к call-like транспорту. +3. Остальные доверенные межсессионные команды одного пользователя. + +## Что важно учесть при переносе + +- не ломать обратную совместимость работающих звонков; +- сохранить текущую маршрутизацию по `sessionId`; +- договориться о едином `signalType`; +- отдельно описать миграцию клиентских обработчиков событий: + - `IncomingCallSignal` -> `IncomingSignal` + +## С какого сценария продолжать + +Начинать перенос со звонков, но только после отдельной ручной проверки того, что `SendSignal` стабильно отработал на `remote AddBlock`. diff --git a/VERSION.properties b/VERSION.properties index 70595d8..0735d24 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.282 -server.version=1.2.262 +client.version=1.2.283 +server.version=1.2.263 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 289529e..9b39f73 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -55,6 +55,7 @@ import * as settingsView from './pages/settings-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js'; import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240'; import * as toolsSettingsView from './pages/tools-settings-view.js'; +import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300'; import * as deviceView from './pages/device-view.js?v=202606131435'; import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055'; import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940'; @@ -101,6 +102,7 @@ const routes = { 'developer-settings-view': developerSettingsView, 'server-settings-view': serverSettingsView, 'tools-settings-view': toolsSettingsView, + 'remote-addblock-session-view': remoteAddBlockSessionView, 'device-view': deviceView, 'connect-device-view': connectDeviceView, 'device-pairing-view': clientPairingView, diff --git a/shine-UI/js/pages/device-pairing-view.js b/shine-UI/js/pages/device-pairing-view.js index 5496d7f..9972a60 100644 --- a/shine-UI/js/pages/device-pairing-view.js +++ b/shine-UI/js/pages/device-pairing-view.js @@ -205,7 +205,7 @@ export function render({ navigate }) { Активные заявки -

Показываются все активные заявки. Для wallet-заявки можно выпустить отдельную session-only сессию без передачи постоянных ключей.

+

Показываются все активные заявки. Для wallet-заявки выпускается отдельная session-only сессия без передачи постоянных ключей.

`; diff --git a/shine-UI/js/pages/remote-addblock-session-view.js b/shine-UI/js/pages/remote-addblock-session-view.js new file mode 100644 index 0000000..12d2a5a --- /dev/null +++ b/shine-UI/js/pages/remote-addblock-session-view.js @@ -0,0 +1,147 @@ +import { renderHeader } from '../components/header.js'; +import { + isSessionInvalidError, + refreshSessions, + saveEntrySettings, + setAuthError, + setAuthInfo, + state, + terminateCurrentSession, +} from '../state.js'; + +export const pageMeta = { id: 'remote-addblock-session-view', title: 'AddBlock через homeserver' }; + +const SESSION_TYPE_HOMESERVER = 100; + +function formatSessionTime(ms) { + return new Date(ms).toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function sessionLabel(session) { + const title = String(session?.clientInfoFromClient || '').trim(); + if (title) return title; + return `Homeserver ${String(session?.sessionId || '').slice(0, 12)}`; +} + +export function render({ navigate }) { + const screen = document.createElement('section'); + screen.className = 'stack'; + + screen.append( + renderHeader({ + title: 'AddBlock через homeserver', + leftAction: { label: '←', onClick: () => navigate('settings-view') }, + }), + ); + + const actions = document.createElement('div'); + actions.className = 'card stack'; + actions.innerHTML = ` +

Если на устройстве нет локального blockchain key, UI сможет отправлять AddBlock в выбранную homeserver-сессию. Homeserver подпишет блок своим blockchain key и сам отправит обычный AddBlock на сервер.

+ + + `; + + const listCard = document.createElement('div'); + listCard.className = 'card stack'; + + const buildList = () => { + listCard.innerHTML = ''; + const selectedId = String(state.entrySettings.remoteAddBlockSessionId || '').trim(); + const sessions = (state.sessions || []) + .filter((item) => Number(item?.sessionType || 0) === SESSION_TYPE_HOMESERVER) + .sort((a, b) => Number(b?.lastAuthenticatedAtMs || 0) - Number(a?.lastAuthenticatedAtMs || 0)); + + if (sessions.length === 0) { + const empty = document.createElement('p'); + empty.className = 'meta-muted'; + empty.textContent = 'Активных homeserver-сессий не найдено.'; + listCard.append(empty); + return; + } + + sessions.forEach((session) => { + const sessionId = String(session?.sessionId || '').trim(); + const item = document.createElement('button'); + item.className = 'session-item'; + item.type = 'button'; + const isSelected = sessionId && sessionId === selectedId; + item.innerHTML = ` +
+ ${sessionLabel(session)} + ${session.geo || 'unknown'} · ${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())} + sessionId: ${sessionId || '-'} + status: ${session.onlineOnThisServer ? 'online' : 'offline on this server'} + ${isSelected ? 'Выбрана для remote AddBlock' : ''} +
+ `; + item.addEventListener('click', async () => { + try { + await saveEntrySettings({ + ...state.entrySettings, + remoteAddBlockSessionId: sessionId, + }); + buildList(); + setAuthInfo('Homeserver-сессия для remote AddBlock сохранена.'); + } catch (error) { + if (isSessionInvalidError(error)) { + await terminateCurrentSession({ + infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.', + }); + return; + } + setAuthError(error.message); + window.alert(error.message); + } + }); + listCard.append(item); + }); + }; + + actions.querySelector('#remote-addblock-refresh')?.addEventListener('click', async () => { + try { + await refreshSessions(); + buildList(); + setAuthInfo('Список homeserver-сессий обновлён.'); + } catch (error) { + if (isSessionInvalidError(error)) { + await terminateCurrentSession({ + infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.', + }); + return; + } + setAuthError(error.message); + window.alert(error.message); + } + }); + + actions.querySelector('#remote-addblock-clear')?.addEventListener('click', async () => { + try { + await saveEntrySettings({ + ...state.entrySettings, + remoteAddBlockSessionId: '', + }); + buildList(); + setAuthInfo('Выбор homeserver-сессии для remote AddBlock сброшен.'); + } catch (error) { + if (isSessionInvalidError(error)) { + await terminateCurrentSession({ + infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.', + }); + return; + } + setAuthError(error.message); + window.alert(error.message); + } + }); + + buildList(); + screen.append(actions, listCard); + return screen; +} diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js index cf472fb..805d261 100644 --- a/shine-UI/js/pages/settings-view.js +++ b/shine-UI/js/pages/settings-view.js @@ -41,6 +41,7 @@ export function render({ navigate }) { card.className = 'card stack'; card.innerHTML = ` + @@ -48,6 +49,7 @@ export function render({ navigate }) { `; card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view')); + card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view')); card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view')); card.querySelector('#settings-tools').addEventListener('click', () => navigate('tools-settings-view')); card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view')); diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index aa83be5..676d5bf 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -178,6 +178,7 @@ export function resolveToolbarActive(pageId) { pageId === 'developer-settings-view' || pageId === 'server-settings-view' || pageId === 'tools-settings-view' || + pageId === 'remote-addblock-session-view' || pageId === 'device-view' || pageId === 'connect-device-view' || pageId === 'device-pairing-view' || diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index d02254f..1b19e5b 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -56,6 +56,11 @@ const CHANNEL_TYPE_GROUP = 200; const CHANNEL_TYPE_VERSION_DEFAULT = 1; const SESSION_TYPE_CLIENT = 1; const SESSION_TYPE_WALLET = 50; +const SESSION_TYPE_HOMESERVER = 100; +const SIGNAL_TARGET_SINGLE = 'single_session'; +const SIGNAL_TARGET_ALL = 'all_sessions'; +const SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST = 'remote_addblock_request'; +const SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT = 'remote_addblock_result'; const CONNECTION_SUBTYPES = Object.freeze({ // Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11. @@ -108,6 +113,10 @@ function opError(op, response) { return error; } +function createSignalRequestId(prefix = 'signal') { + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + function makeClientInfo() { const ua = navigator.userAgent || 'unknown'; return ua.slice(0, 50); @@ -137,6 +146,11 @@ function normalizeHex32(value, fallback = ZERO64) { return raw; } +async function sha256Base64FromText(text) { + const digest = await sha256Bytes(utf8Bytes(String(text || ''))); + return bytesToBase64(digest); +} + function concatBytes(...chunks) { const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const out = new Uint8Array(total); @@ -745,6 +759,9 @@ export class AuthService { this.writeLocks = new Map(); this.passwordKeyBundleCache = new Map(); this.passwordKeyBundleInFlight = new Map(); + this.currentLogin = ''; + this.currentSessionId = ''; + this.remoteAddBlockSessionId = ''; } async reconnect(serverUrl) { @@ -757,6 +774,20 @@ export class AuthService { this.writeLocks.clear(); } + setActiveSessionContext({ login = '', sessionId = '' } = {}) { + this.currentLogin = String(login || '').trim(); + this.currentSessionId = String(sessionId || '').trim(); + } + + clearActiveSessionContext() { + this.currentLogin = ''; + this.currentSessionId = ''; + } + + setRemoteAddBlockSessionId(sessionId = '') { + this.remoteAddBlockSessionId = String(sessionId || '').trim(); + } + runWriteLocked(lockKey, runAction) { const key = String(lockKey || '').trim() || 'write'; if (this.writeLocks.has(key)) return this.writeLocks.get(key); @@ -1096,6 +1127,100 @@ export class AuthService { return response?.payload?.sessions || []; } + async waitForSignal({ signalType, signalRequestId, timeoutMs = 15000 }) { + const cleanSignalType = String(signalType || '').trim(); + const cleanSignalRequestId = String(signalRequestId || '').trim(); + if (!cleanSignalType || !cleanSignalRequestId) { + throw new Error('waitForSignal: не переданы signalType/signalRequestId'); + } + + return new Promise((resolve, reject) => { + let unsubscribe = () => {}; + const timer = window.setTimeout(() => { + unsubscribe(); + reject(new Error(`Таймаут ожидания сигнала ${cleanSignalType}`)); + }, timeoutMs); + + unsubscribe = this.onEvent('IncomingSignal', (evt) => { + const payload = evt?.payload || {}; + if (String(payload?.signalType || '').trim() !== cleanSignalType) return; + if (String(payload?.signalRequestId || '').trim() !== cleanSignalRequestId) return; + window.clearTimeout(timer); + unsubscribe(); + resolve(payload); + }); + }); + } + + async sendSignal({ + toLogin, + targetMode = SIGNAL_TARGET_SINGLE, + targetSessionId = '', + signalType, + signalRequestId, + data = '', + storagePwd = '', + includeClientSignature = true, + timeMs = Date.now(), + } = {}) { + const cleanToLogin = String(toLogin || '').trim(); + const cleanTargetMode = String(targetMode || '').trim(); + const cleanTargetSessionId = String(targetSessionId || '').trim(); + const cleanSignalType = String(signalType || '').trim(); + const cleanSignalRequestId = String(signalRequestId || '').trim(); + const cleanLogin = String(this.currentLogin || '').trim(); + const cleanSessionId = String(this.currentSessionId || '').trim(); + if (!cleanLogin || !cleanSessionId) throw new Error('SendSignal: нет активного login/sessionId'); + if (!cleanToLogin || !cleanSignalType || !cleanSignalRequestId) { + throw new Error('SendSignal: не переданы toLogin/signalType/signalRequestId'); + } + if (cleanTargetMode !== SIGNAL_TARGET_SINGLE && cleanTargetMode !== SIGNAL_TARGET_ALL) { + throw new Error('SendSignal: bad targetMode'); + } + if (cleanTargetMode === SIGNAL_TARGET_SINGLE && !cleanTargetSessionId) { + throw new Error('SendSignal: targetSessionId обязателен для single_session'); + } + + const sessionMaterial = await loadSessionMaterial(cleanLogin); + if (!sessionMaterial?.sessionPrivPkcs8) { + throw new Error('На устройстве нет сохранённого session key для SendSignal'); + } + const sessionPrivateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8); + + const dataText = typeof data === 'string' ? data : JSON.stringify(data || {}); + const dataSha256B64 = await sha256Base64FromText(dataText); + + const sessionPreimage = `SEND_SIGNAL_SESSION:${cleanLogin}:${cleanSessionId}:${cleanToLogin}:${cleanTargetMode}:${cleanTargetSessionId}:${cleanSignalType}:${cleanSignalRequestId}:${Number(timeMs)}:${dataSha256B64}`; + const sessionSignatureB64 = await signBase64(sessionPrivateKey, sessionPreimage); + + let clientSignatureB64 = ''; + if (includeClientSignature) { + if (!storagePwd) throw new Error('SendSignal: нужен storagePwd для подписи client key'); + const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd); + const clientPrivatePkcs8 = String(secrets?.clientKey || '').trim(); + if (!clientPrivatePkcs8) { + throw new Error('На устройстве нет сохранённого client key для SendSignal'); + } + const clientPrivateKey = await importPkcs8Ed25519(clientPrivatePkcs8); + const clientPreimage = `SEND_SIGNAL_CLIENT:${cleanLogin}:${cleanSessionId}:${cleanToLogin}:${cleanTargetMode}:${cleanTargetSessionId}:${cleanSignalType}:${cleanSignalRequestId}:${Number(timeMs)}:${dataSha256B64}`; + clientSignatureB64 = await signBase64(clientPrivateKey, clientPreimage); + } + + const response = await this.ws.request('SendSignal', { + toLogin: cleanToLogin, + targetMode: cleanTargetMode, + targetSessionId: cleanTargetMode === SIGNAL_TARGET_SINGLE ? cleanTargetSessionId : '', + signalType: cleanSignalType, + signalRequestId: cleanSignalRequestId, + data: dataText, + timeMs: Number(timeMs), + sessionSignatureB64, + clientSignatureB64, + }); + if (response.status !== 200) throw opError('SendSignal', response); + return response.payload || {}; + } + async closeSession(sessionId) { const response = await this.ws.request('CloseActiveSession', { sessionId }); if (response.status !== 200) throw opError('CloseActiveSession', response); @@ -1219,56 +1344,115 @@ export class AuthService { return response.payload || {}; } - async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) { - const cleanLogin = (login || '').trim(); - if (!cleanLogin) throw new Error('Missing login for AddBlock'); - if (!storagePwd) throw new Error('Missing storagePwd for AddBlock signing'); - - const resolveFreshCursor = async () => { - const user = await this.getUser(cleanLogin); - const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); - const freshNum = Number(user?.serverLastGlobalNumber); - const freshHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64); - return { - blockchainName, - cursor: { - serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1, - serverLastGlobalHash: freshHash, - }, - }; + async resolveFreshBlockchainCursor(login) { + const cleanLogin = String(login || '').trim(); + const user = await this.getUser(cleanLogin); + const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); + const freshNum = Number(user?.serverLastGlobalNumber); + const freshHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64); + return { + blockchainName, + cursor: { + serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1, + serverLastGlobalHash: freshHash, + }, }; - const freshState = await resolveFreshCursor(); - const blockchainName = freshState.blockchainName; + } - const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd); - const blockchainPrivatePkcs8 = savedKeys?.blockchainKey; - if (!blockchainPrivatePkcs8) { - throw new Error('Missing saved blockchain private key on device'); + async submitPreparedAddBlock({ login, storagePwd, blockchainName, blockNumber, prevBlockHash, preimage }) { + const cleanLogin = String(login || '').trim(); + const cleanBlockchainName = String(blockchainName || '').trim(); + const cleanPrevBlockHash = normalizeHex32(prevBlockHash, ZERO_HASH_HEX); + if (!cleanLogin || !cleanBlockchainName) throw new Error('submitPreparedAddBlock: missing login/blockchainName'); + if (!(preimage instanceof Uint8Array) || preimage.length === 0) { + throw new Error('submitPreparedAddBlock: bad preimage'); } - const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8); + const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd); + const blockchainPrivatePkcs8 = String(savedKeys?.blockchainKey || '').trim(); + if (blockchainPrivatePkcs8) { + const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8); + const hash32 = await sha256Bytes(preimage); + const signatureBytes = await signBytes(privateKey, hash32); + const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes); + return this.ws.request('AddBlock', { + blockchainName: cleanBlockchainName, + blockNumber: Number(blockNumber), + prevBlockHash: cleanPrevBlockHash, + blockBytesB64: bytesToBase64(fullBlock), + }); + } + + const remoteSessionId = String(this.remoteAddBlockSessionId || '').trim(); + if (!remoteSessionId) { + throw new Error('На устройстве нет blockchain key и не выбрана homeserver-сессия для remote AddBlock'); + } + + const signalRequestId = createSignalRequestId('remote-addblock'); + const responseWait = this.waitForSignal({ + signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT, + signalRequestId, + timeoutMs: 20000, + }); + + const signalData = { + operation: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST, + signalRequestId, + login: cleanLogin, + blockchainName: cleanBlockchainName, + blockNumber: Number(blockNumber), + prevBlockHash: cleanPrevBlockHash, + blockPreimageB64: bytesToBase64(preimage), + }; + + await this.sendSignal({ + toLogin: cleanLogin, + targetMode: SIGNAL_TARGET_SINGLE, + targetSessionId: remoteSessionId, + signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST, + signalRequestId, + data: JSON.stringify(signalData), + storagePwd, + includeClientSignature: true, + }); + + const signalPayload = await responseWait; + let result = {}; + try { + result = JSON.parse(String(signalPayload?.data || '{}')); + } catch { + throw new Error('Некорректный ответ remote AddBlock от homeserver'); + } + if (!result?.ok) { + throw new Error(String(result?.errorMessage || result?.error || 'remote_addblock_failed')); + } + + return { + status: 200, + payload: { + serverLastGlobalNumber: Number(result?.serverLastGlobalNumber ?? blockNumber), + serverLastGlobalHash: String(result?.serverLastGlobalHash || ZERO_HASH_HEX), + remote: true, + }, + }; + } + + async runAddBlockWithRetry({ login, storagePwd, resolveFreshState, buildPreimage }) { + let freshState = await resolveFreshState(); + let blockchainName = String(freshState?.blockchainName || '').trim(); + if (!blockchainName) throw new Error('runAddBlockWithRetry: blockchainName is empty'); const tryAdd = async (cursor) => { const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1; const prevBlockHash = normalizeHex32(cursor?.serverLastGlobalHash, ZERO64); - const preimage = buildBlockPreimage({ - prevBlockHashHex: prevBlockHash, - blockNumber, - msgType, - msgSubType, - msgVersion, - bodyBytes, - }); - - const hash32 = await sha256Bytes(preimage); - const signatureBytes = await signBytes(privateKey, hash32); - const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes); - - return this.ws.request('AddBlock', { + const preimage = await buildPreimage({ blockNumber, prevBlockHash, blockchainName }); + return this.submitPreparedAddBlock({ + login, + storagePwd, blockchainName, blockNumber, prevBlockHash, - blockBytesB64: bytesToBase64(fullBlock), + preimage, }); }; @@ -1281,13 +1465,38 @@ export class AuthService { cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash.toLowerCase() }; response = await tryAdd(cursor); } else { - const refreshed = await resolveFreshCursor(); - cursor = refreshed.cursor; + freshState = await resolveFreshState(); + blockchainName = String(freshState?.blockchainName || blockchainName).trim() || blockchainName; + cursor = freshState.cursor; response = await tryAdd(cursor); } } if (response.status !== 200) throw opError('AddBlock', response); + return { + response, + blockchainName, + }; + } + + async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) { + const cleanLogin = (login || '').trim(); + if (!cleanLogin) throw new Error('Missing login for AddBlock'); + if (!storagePwd) throw new Error('Missing storagePwd for AddBlock signing'); + + const { response, blockchainName } = await this.runAddBlockWithRetry({ + login: cleanLogin, + storagePwd, + resolveFreshState: () => this.resolveFreshBlockchainCursor(cleanLogin), + buildPreimage: async ({ blockNumber, prevBlockHash }) => buildBlockPreimage({ + prevBlockHashHex: prevBlockHash, + blockNumber, + msgType, + msgSubType, + msgVersion, + bodyBytes, + }), + }); const payload = response.payload || {}; const acceptedNum = Number(payload?.serverLastGlobalNumber); @@ -2249,78 +2458,32 @@ export class AuthService { if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param.'); if (!cleanValue) throw new Error('Значение параметра не может быть пустым.'); if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.'); - - const user = await this.getUser(cleanLogin); - const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); - const freshNum = Number(user?.serverLastGlobalNumber); - const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase(); - const freshCursor = { - serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1, - serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX, - }; - - const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd); - const blockchainPrivatePkcs8 = savedKeys?.blockchainKey; - if (!blockchainPrivatePkcs8) { - throw new Error('На устройстве нет сохраненного приватного blockchainKey'); - } - - const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8); - - const tryAdd = async (cursor) => { - const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1; - const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX); - - // Для USER_PARAM отправляем старт новой line-цепочки: - // prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1. - // Этот формат соответствует BodyHasLine правилам на сервере. - const bodyBytes = makeUserParamBodyBytes({ - lineCode: 0, - prevLineNumber: -1, - prevLineHashHex: ZERO_HASH_HEX, - thisLineNumber: -1, - key: cleanParam, - value: cleanValue, - }); - - const preimage = concatBytes( - int16Bytes(0), - hexToBytes(prevBlockHash), - int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length), - int32Bytes(blockNumber), - int64Bytes(Math.floor(Date.now() / 1000)), - int16Bytes(4), - int16Bytes(1), - int16Bytes(1), - bodyBytes, - ); - - const hash32 = await sha256Bytes(preimage); - const signatureBytes = await signBytes(privateKey, hash32); - const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes); - - const response = await this.ws.request('AddBlock', { - blockchainName, - blockNumber, - prevBlockHash, - blockBytesB64: bytesToBase64(fullBlock), - }); - - return response; - }; - - let cursor = freshCursor; - let response = await tryAdd(cursor); - if (response.status !== 200) { - const knownNum = Number(response?.payload?.serverLastGlobalNumber); - const knownHash = String(response?.payload?.serverLastGlobalHash || ''); - if (Number.isFinite(knownNum) && knownHash.length === 64) { - cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash }; - response = await tryAdd(cursor); - } - } - - if (response.status !== 200) throw opError('AddBlock', response); + const { response } = await this.runAddBlockWithRetry({ + login: cleanLogin, + storagePwd, + resolveFreshState: () => this.resolveFreshBlockchainCursor(cleanLogin), + buildPreimage: async ({ blockNumber, prevBlockHash }) => { + const bodyBytes = makeUserParamBodyBytes({ + lineCode: 0, + prevLineNumber: -1, + prevLineHashHex: ZERO_HASH_HEX, + thisLineNumber: -1, + key: cleanParam, + value: cleanValue, + }); + return concatBytes( + int16Bytes(0), + hexToBytes(prevBlockHash), + int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length), + int32Bytes(blockNumber), + int64Bytes(Math.floor(Date.now() / 1000)), + int16Bytes(4), + int16Bytes(1), + int16Bytes(1), + bodyBytes, + ); + }, + }); return response.payload || {}; } @@ -2337,78 +2500,37 @@ export class AuthService { const user = await this.getUser(cleanLogin); if (user?.exists === false) throw new Error('Текущий пользователь не найден.'); - const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); - const freshNum = Number(user?.serverLastGlobalNumber); - const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase(); - const freshCursor = { - serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1, - serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX, - }; const targetUser = await this.getUser(cleanToLogin); if (!targetUser?.exists) throw new Error('Пользователь цели не найден.'); const toBlockchainName = String(targetUser?.blockchainName || `${cleanToLogin}-${BCH_SUFFIX}`).trim(); - - const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd); - const blockchainPrivatePkcs8 = savedKeys?.blockchainKey; - if (!blockchainPrivatePkcs8) { - throw new Error('На устройстве нет сохраненного приватного blockchainKey'); - } - const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8); - - const tryAdd = async (cursor) => { - const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1; - const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX); - - // Для CONNECTION в UI-MVP всегда стартуем новую line-цепочку: - // prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1. - // target для user-связей указывает на HEADER пользователя (blockNumber=0). - const bodyBytes = makeConnectionBodyBytes({ - lineCode: 0, - prevLineNumber: -1, - prevLineHashHex: ZERO_HASH_HEX, - thisLineNumber: -1, - toBlockchainName, - toBlockNumber: 0, - toBlockHashHex: ZERO_HASH_HEX, - }); - - const preimage = concatBytes( - int16Bytes(0), - hexToBytes(prevBlockHash), - int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length), - int32Bytes(blockNumber), - int64Bytes(Math.floor(Date.now() / 1000)), - int16Bytes(3), - int16Bytes(cleanSubType), - int16Bytes(1), - bodyBytes, - ); - - const hash32 = await sha256Bytes(preimage); - const signatureBytes = await signBytes(privateKey, hash32); - const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes); - - return this.ws.request('AddBlock', { - blockchainName, - blockNumber, - prevBlockHash, - blockBytesB64: bytesToBase64(fullBlock), - }); - }; - - let cursor = freshCursor; - let response = await tryAdd(cursor); - if (response.status !== 200) { - const knownNum = Number(response?.payload?.serverLastGlobalNumber); - const knownHash = String(response?.payload?.serverLastGlobalHash || '').trim().toLowerCase(); - if (Number.isFinite(knownNum) && knownHash.length === 64) { - cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash }; - response = await tryAdd(cursor); - } - } - - if (response.status !== 200) throw opError('AddBlock', response); + const { response } = await this.runAddBlockWithRetry({ + login: cleanLogin, + storagePwd, + resolveFreshState: () => this.resolveFreshBlockchainCursor(cleanLogin), + buildPreimage: async ({ blockNumber, prevBlockHash }) => { + const bodyBytes = makeConnectionBodyBytes({ + lineCode: 0, + prevLineNumber: -1, + prevLineHashHex: ZERO_HASH_HEX, + thisLineNumber: -1, + toBlockchainName, + toBlockNumber: 0, + toBlockHashHex: ZERO_HASH_HEX, + }); + return concatBytes( + int16Bytes(0), + hexToBytes(prevBlockHash), + int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length), + int32Bytes(blockNumber), + int64Bytes(Math.floor(Date.now() / 1000)), + int16Bytes(3), + int16Bytes(cleanSubType), + int16Bytes(1), + bodyBytes, + ); + }, + }); return response.payload || {}; } diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index 5093546..69aa51a 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -188,6 +188,7 @@ function persistEntrySettings(settings) { shineServerHttp: String(settings?.shineServerHttp || DEFAULT_SHINE_SERVER_HTTP_VALUE), arweaveServer: String(settings?.arweaveServer || DEFAULT_ARWEAVE_SERVER), callPreflightTimeoutMs: Math.max(1000, Math.min(20000, Number(settings?.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS) || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS)), + remoteAddBlockSessionId: String(settings?.remoteAddBlockSessionId || ''), statuses: { solanaServer: String(settings?.statuses?.solanaServer || 'idle'), shineServerLogin: String(settings?.statuses?.shineServerLogin || settings?.statuses?.shineServer || 'idle'), @@ -254,6 +255,7 @@ function createInitialState({ withStoredSession = true } = {}) { shineServerHttp: String(storedEntrySettings?.shineServerHttp || DEFAULT_SHINE_SERVER_HTTP_VALUE), arweaveServer: String(storedEntrySettings?.arweaveServer || DEFAULT_ARWEAVE_SERVER), callPreflightTimeoutMs: Math.max(1000, Math.min(20000, Number(storedEntrySettings?.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS) || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS)), + remoteAddBlockSessionId: String(storedEntrySettings?.remoteAddBlockSessionId || ''), statuses: { solanaServer: String(storedEntrySettings?.statuses?.solanaServer || 'idle'), shineServerLogin: String(storedEntrySettings?.statuses?.shineServerLogin || storedEntrySettings?.statuses?.shineServer || 'idle'), @@ -316,6 +318,11 @@ function createInitialState({ withStoredSession = true } = {}) { export const state = createInitialState(); export const authService = new AuthService(state.entrySettings.shineServer); +authService.setRemoteAddBlockSessionId(state.entrySettings.remoteAddBlockSessionId); +authService.setActiveSessionContext({ + login: state.session.login, + sessionId: state.session.sessionId, +}); let onSessionReset = null; let onSessionAuthorized = null; @@ -738,6 +745,7 @@ export async function saveEntrySettings(nextSettings) { tools: normalizeToolsSettings(nextSettings.tools || state.entrySettings.tools), }; persistEntrySettings(state.entrySettings); + authService.setRemoteAddBlockSessionId(state.entrySettings.remoteAddBlockSessionId); await authService.reconnect(state.entrySettings.shineServer); state.startHint = `Настройки входа сохранены. SHiNE: ${state.entrySettings.shineServerHttp}`; } @@ -777,6 +785,7 @@ export function authorizeSession({ login, sessionId, }); + authService.setActiveSessionContext({ login, sessionId }); state.startHint = ''; if (onSessionAuthorized) { onSessionAuthorized(); @@ -851,6 +860,7 @@ export async function terminateCurrentSession({ infoMessage = '', closeServerSes resetStateForSignedOut(); await clearStoredMessages().catch(() => {}); authService.close(); + authService.clearActiveSessionContext(); if (infoMessage) { state.startHint = infoMessage; }