Добавить SendSignal и remote AddBlock через homeserver

This commit is contained in:
AidarKC 2026-06-28 11:20:51 +04:00
parent c397c28acb
commit aa02e92e4d
19 changed files with 1329 additions and 224 deletions

View File

@ -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-слой и политика доступа.

View File

@ -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-лог |

View File

@ -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.

View File

@ -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. Роли и ограничения

View File

@ -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.

View File

@ -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<PendingWalletRpcRequest> gPendingWalletRpcRequests;
static std::vector<PendingRemoteAddBlockRequest> 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<const uint8_t *>(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<uint8_t> sessionMessage(reinterpret_cast<const uint8_t *>(sessionPreimage.c_str()),
reinterpret_cast<const uint8_t *>(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<uint8_t> clientMessage(reinterpret_cast<const uint8_t *>(clientPreimage.c_str()),
reinterpret_cast<const uint8_t *>(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<uint8_t> 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<uint8_t>(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<uint8_t> 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) {

View File

@ -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),

View File

@ -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<ConnectionContext> 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<String> 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<ConnectionContext> resolveTargets(String targetMode, String toLogin, String targetSessionId) {
List<ConnectionContext> 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<ConnectionContext> 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();
}
}

View File

@ -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;
}
}

View File

@ -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<String> deliveredSessionIds = new ArrayList<>();
public int getDeliveredCount() {
return deliveredCount;
}
public void setDeliveredCount(int deliveredCount) {
this.deliveredCount = deliveredCount;
}
public List<String> getDeliveredSessionIds() {
return deliveredSessionIds;
}
public void setDeliveredSessionIds(List<String> deliveredSessionIds) {
this.deliveredSessionIds = deliveredSessionIds;
}
}

View File

@ -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`.

View File

@ -1,2 +1,2 @@
client.version=1.2.282
server.version=1.2.262
client.version=1.2.283
server.version=1.2.263

View File

@ -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,

View File

@ -205,7 +205,7 @@ export function render({ navigate }) {
<span class="field-label">Активные заявки</span>
<button class="ghost-btn" type="button" id="refresh-pairing-requests">Обновить заявки</button>
</div>
<p class="meta-muted">Показываются все активные заявки. Для wallet-заявки можно выпустить отдельную session-only сессию без передачи постоянных ключей.</p>
<p class="meta-muted">Показываются все активные заявки. Для wallet-заявки выпускается отдельная session-only сессия без передачи постоянных ключей.</p>
<div class="stack" id="pairing-requests-list"></div>
`;

View File

@ -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 = `
<p class="meta-muted">Если на устройстве нет локального blockchain key, UI сможет отправлять AddBlock в выбранную homeserver-сессию. Homeserver подпишет блок своим blockchain key и сам отправит обычный AddBlock на сервер.</p>
<button class="primary-btn" type="button" id="remote-addblock-refresh">Обновить список homeserver-сессий</button>
<button class="ghost-btn" type="button" id="remote-addblock-clear">Сбросить выбор</button>
`;
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 = `
<div class="stack" style="gap:4px; text-align:left;">
<strong>${sessionLabel(session)}</strong>
<span class="meta-muted">${session.geo || 'unknown'} · ${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>
<span class="meta-muted">sessionId: ${sessionId || '-'}</span>
<span class="meta-muted">status: ${session.onlineOnThisServer ? 'online' : 'offline on this server'}</span>
${isSelected ? '<span class="session-current-badge">Выбрана для remote AddBlock</span>' : ''}
</div>
`;
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;
}

View File

@ -41,6 +41,7 @@ export function render({ navigate }) {
card.className = 'card stack';
card.innerHTML = `
<button class="text-btn" type="button" id="settings-device">Устройства</button>
<button class="text-btn" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
<button class="text-btn" type="button" id="settings-tools">Настройки инструментов ввода</button>
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
@ -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'));

View File

@ -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' ||

View File

@ -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 || {};
}

View File

@ -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;
}