Добавить SendSignal и remote AddBlock через homeserver
This commit is contained in:
parent
c397c28acb
commit
aa02e92e4d
@ -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-слой и политика доступа.
|
||||
|
||||
@ -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-лог |
|
||||
|
||||
@ -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.
|
||||
@ -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. Роли и ограничения
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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`.
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.282
|
||||
server.version=1.2.262
|
||||
client.version=1.2.283
|
||||
server.version=1.2.263
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
|
||||
|
||||
147
shine-UI/js/pages/remote-addblock-session-view.js
Normal file
147
shine-UI/js/pages/remote-addblock-session-view.js
Normal 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;
|
||||
}
|
||||
@ -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'));
|
||||
|
||||
@ -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' ||
|
||||
|
||||
@ -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 || {};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user