Добавить SendSignal и remote AddBlock через homeserver
This commit is contained in:
parent
c397c28acb
commit
aa02e92e4d
@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
Этот файл описывает технические WebSocket-запросы, которые нужны для служебной работы клиента с сервером. Часть операций доступна без авторизации, часть требует успешной авторизованной сессии.
|
Этот файл описывает технические WebSocket-запросы, которые нужны для служебной работы клиента с сервером. Часть операций доступна без авторизации, часть требует успешной авторизованной сессии.
|
||||||
|
|
||||||
Сейчас здесь восемь методов:
|
Сейчас здесь девять методов:
|
||||||
|
|
||||||
- `Ping` — keep-alive запрос для поддержания живого WebSocket-соединения;
|
- `Ping` — keep-alive запрос для поддержания живого WebSocket-соединения;
|
||||||
- `GetServerInfo` — запрос базовой публичной информации о сервере для выбора узла в децентрализованной сети;
|
- `GetServerInfo` — запрос базовой публичной информации о сервере для выбора узла в децентрализованной сети;
|
||||||
- `ListBlockchainHeads` — краткая сводка по всем локальным блокчейнам сервера для межсерверной синхронизации;
|
- `ListBlockchainHeads` — краткая сводка по всем локальным блокчейнам сервера для межсерверной синхронизации;
|
||||||
- `GetSyncUserProfile` — межсерверный профиль пользователя для создания локальной цепочки без Solana RPC;
|
- `GetSyncUserProfile` — межсерверный профиль пользователя для создания локальной цепочки без Solana RPC;
|
||||||
|
- `SendSignal` — общий межсессионный технический сигнал в одну конкретную сессию или сразу во все активные сессии пользователя;
|
||||||
- `GetCallIceConfig` — выдача STUN/TURN конфигурации для звонков;
|
- `GetCallIceConfig` — выдача STUN/TURN конфигурации для звонков;
|
||||||
- `ClientErrorLog` — отправка клиентской ошибки в серверный лог;
|
- `ClientErrorLog` — отправка клиентской ошибки в серверный лог;
|
||||||
- `ClientDebugLog` — отправка клиентского debug-события в серверный буфер;
|
- `ClientDebugLog` — отправка клиентского debug-события в серверный буфер;
|
||||||
@ -19,6 +20,7 @@
|
|||||||
- `GetServerInfo` нужен до авторизации и до работы с данными, чтобы клиент понял, что сервер доступен, и показал пользователю краткую карточку этого узла.
|
- `GetServerInfo` нужен до авторизации и до работы с данными, чтобы клиент понял, что сервер доступен, и показал пользователю краткую карточку этого узла.
|
||||||
- `ListBlockchainHeads` нужен для сервер-сервер сверки: партнёр получает список heads по всем цепочкам, сравнивает его со своим состоянием и затем добирает недостающие блоки по диапазону.
|
- `ListBlockchainHeads` нужен для сервер-сервер сверки: партнёр получает список heads по всем цепочкам, сравнивает его со своим состоянием и затем добирает недостающие блоки по диапазону.
|
||||||
- `GetSyncUserProfile` нужен для server-to-server режима, когда принимающий сервер хочет создать у себя локальные `solana_users + blockchain_state` без прямого обращения в Solana. Это используется как временный обход ограничений внешнего Solana RPC.
|
- `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-соединение живо.
|
- `Ping` нужен для keep-alive и проверки, что WebSocket-соединение живо.
|
||||||
- `GetServerInfo` нужен для выбора сервера в сети и показа публичной информации об узле.
|
- `GetServerInfo` нужен для выбора сервера в сети и показа публичной информации об узле.
|
||||||
|
- `SendSignal` нужен для доверенных межсессионных сигналов одного пользователя, включая `remote AddBlock via homeserver session`.
|
||||||
- `GetCallIceConfig` нужен для WebRTC-звонков и требует авторизации.
|
- `GetCallIceConfig` нужен для WebRTC-звонков и требует авторизации.
|
||||||
- `ClientErrorLog`, `ClientDebugLog`, `CallDeliveryReport` используются для диагностики клиента и звонков.
|
- `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` | публичная информация о сервере |
|
| `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере |
|
||||||
| `ListBlockchainHeads` | `05_Technical_Requests_API.md` | список heads всех локальных блокчейнов |
|
| `ListBlockchainHeads` | `05_Technical_Requests_API.md` | список heads всех локальных блокчейнов |
|
||||||
| `GetSyncUserProfile` | `05_Technical_Requests_API.md` | межсерверный профиль пользователя для синхронизации |
|
| `GetSyncUserProfile` | `05_Technical_Requests_API.md` | межсерверный профиль пользователя для синхронизации |
|
||||||
|
| `SendSignal` | `05_Technical_Requests_API.md` | общий межсессионный технический сигнал в одну или все сессии пользователя |
|
||||||
| `GetCallIceConfig` | `05_Technical_Requests_API.md` | STUN/TURN конфигурация звонков |
|
| `GetCallIceConfig` | `05_Technical_Requests_API.md` | STUN/TURN конфигурация звонков |
|
||||||
| `ClientErrorLog` | `05_Technical_Requests_API.md` | логирование клиентской ошибки |
|
| `ClientErrorLog` | `05_Technical_Requests_API.md` | логирование клиентской ошибки |
|
||||||
| `ClientDebugLog` | `05_Technical_Requests_API.md` | клиентский debug-лог |
|
| `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 и режимы подключения
|
# ESP Pairing и режимы подключения
|
||||||
|
|
||||||
Этот документ фиксирует текущие и новые режимы входа/подключения в SHiNE без клиентской UI-реализации. Он нужен как отдельная точка входа по сценариям подключения, чтобы не смешивать обычную авторизацию и серверный pairing через доверенное уже авторизованное устройство пользователя.
|
Этот документ фиксирует актуальные режимы входа/подключения в SHiNE. Он нужен как отдельная точка входа по сценариям подключения, чтобы не смешивать обычную авторизацию и серверный pairing через доверенное уже авторизованное устройство пользователя.
|
||||||
|
|
||||||
## 1. Текущие режимы
|
## 1. Текущие режимы
|
||||||
|
|
||||||
@ -30,7 +30,7 @@
|
|||||||
- соединение снова входит в существующую сессию;
|
- соединение снова входит в существующую сессию;
|
||||||
- этот поток тоже остаётся без изменений.
|
- этот поток тоже остаётся без изменений.
|
||||||
|
|
||||||
## 2. Новый режим: добавление сессии через доверенное устройство пользователя
|
## 2. Добавление сессии через доверенное устройство пользователя
|
||||||
|
|
||||||
Новый поток не заменяет обычный логин, а живёт рядом с ним.
|
Новый поток не заменяет обычный логин, а живёт рядом с ним.
|
||||||
|
|
||||||
@ -41,7 +41,7 @@
|
|||||||
- реальное доверие даёт любая уже онлайн доверенная сессия пользователя;
|
- реальное доверие даёт любая уже онлайн доверенная сессия пользователя;
|
||||||
- сервер не выдаёт приватные ключи сам от себя.
|
- сервер не выдаёт приватные ключи сам от себя.
|
||||||
|
|
||||||
Поток версии `v1`:
|
Текущий поток:
|
||||||
|
|
||||||
1. Любая доверенная сессия пользователя создаёт на сервере pairing-настройку:
|
1. Любая доверенная сессия пользователя создаёт на сервере pairing-настройку:
|
||||||
`UpsertEspPairingSettings`
|
`UpsertEspPairingSettings`
|
||||||
@ -65,7 +65,7 @@
|
|||||||
- уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены;
|
- уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены;
|
||||||
- хранит переданный `encryptedPayload` как непрозрачную строку и не анализирует его содержимое.
|
- хранит переданный `encryptedPayload` как непрозрачную строку и не анализирует его содержимое.
|
||||||
|
|
||||||
## 4. Чего сервер в этой версии не делает
|
## 4. Чего сервер в этом режиме не делает
|
||||||
|
|
||||||
- не передаёт приватный `clientKey`;
|
- не передаёт приватный `clientKey`;
|
||||||
- не расшифровывает `encryptedPayload`;
|
- не расшифровывает `encryptedPayload`;
|
||||||
@ -73,7 +73,7 @@
|
|||||||
- не делает клиентский UI;
|
- не делает клиентский UI;
|
||||||
- не навязывает конкретную схему `Ed25519 -> X25519` в коде сервера.
|
- не навязывает конкретную схему `Ed25519 -> X25519` в коде сервера.
|
||||||
|
|
||||||
Это намеренно: серверная версия `v1` подготавливает безопасный каркас маршрутизации и состояния, а настоящая E2E-логика упаковки ключей будет жить на клиентах и ESP-устройствах.
|
Это намеренно: сервер остаётся безопасным каркасом маршрутизации и состояния, а E2E-логика упаковки ключей живёт на клиентах и ESP-устройствах.
|
||||||
|
|
||||||
## 5. Роли и ограничения
|
## 5. Роли и ограничения
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Формат взаимодействия внешнего кошелька и ESP32
|
# Формат взаимодействия внешнего кошелька и ESP32
|
||||||
|
|
||||||
Этот документ фиксирует первый этап формата взаимодействия между внешним браузерным wallet-расширением SHiNE и устройством `ESP32-S3-Touch-AMOLED-2.16`.
|
Этот документ фиксирует актуальный формат взаимодействия между внешним браузерным wallet-расширением SHiNE и устройством `ESP32-S3-Touch-AMOLED-2.16`.
|
||||||
|
|
||||||
Документ описывает:
|
Документ описывает:
|
||||||
|
|
||||||
@ -39,13 +39,13 @@ ESP32 возвращает:
|
|||||||
|
|
||||||
## 2. Транспорт и маршрут
|
## 2. Транспорт и маршрут
|
||||||
|
|
||||||
Первая версия формата использует уже существующую `wallet-session` браузерного расширения.
|
Текущий формат использует уже существующую `wallet-session` браузерного расширения.
|
||||||
|
|
||||||
Схема маршрута:
|
Схема маршрута:
|
||||||
|
|
||||||
`browser extension -> SHiNE server -> homeserver session on ESP32 -> SHiNE server -> browser extension`
|
`browser extension -> SHiNE server -> homeserver session on ESP32 -> SHiNE server -> browser extension`
|
||||||
|
|
||||||
В первой версии:
|
В текущем формате:
|
||||||
|
|
||||||
- отдельная цифровая подпись payload не добавляется;
|
- отдельная цифровая подпись payload не добавляется;
|
||||||
- отдельное E2E-шифрование для wallet RPC не добавляется;
|
- отдельное E2E-шифрование для wallet RPC не добавляется;
|
||||||
@ -90,7 +90,7 @@ ESP32 возвращает:
|
|||||||
- `customName`;
|
- `customName`;
|
||||||
- `targetSessionName`.
|
- `targetSessionName`.
|
||||||
|
|
||||||
Они намеренно не входят в первую версию этого запроса.
|
Они намеренно не входят в текущий формат этого запроса.
|
||||||
|
|
||||||
## 4. Формат ответа
|
## 4. Формат ответа
|
||||||
|
|
||||||
@ -121,9 +121,9 @@ ESP32 возвращает:
|
|||||||
- `wallet.publicKeyBase58` — публичный ключ активного кошелька в `Base58`.
|
- `wallet.publicKeyBase58` — публичный ключ активного кошелька в `Base58`.
|
||||||
- `timeMs` — время формирования ответа на стороне ESP32 в миллисекундах.
|
- `timeMs` — время формирования ответа на стороне ESP32 в миллисекундах.
|
||||||
|
|
||||||
## 6. Ошибки первой версии
|
## 6. Ошибки текущего формата
|
||||||
|
|
||||||
Минимальный формат ошибки в первой версии допускается таким:
|
Минимальный формат ошибки допускается таким:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -136,7 +136,7 @@ ESP32 возвращает:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Рекомендуемые коды ошибок первой версии:
|
Рекомендуемые коды ошибок:
|
||||||
|
|
||||||
- `wallet_unavailable` — на устройстве нельзя получить текущий кошелёк;
|
- `wallet_unavailable` — на устройстве нельзя получить текущий кошелёк;
|
||||||
- `secret_not_configured` — на устройстве ещё нет корректно сохранённого секрета;
|
- `secret_not_configured` — на устройстве ещё нет корректно сохранённого секрета;
|
||||||
@ -158,7 +158,7 @@ ESP32 возвращает:
|
|||||||
|
|
||||||
- если `wallet.type = client.key`, то `publicKeyBase58` должен совпасть с `clientKey`, прочитанным из PDA;
|
- если `wallet.type = client.key`, то `publicKeyBase58` должен совпасть с `clientKey`, прочитанным из PDA;
|
||||||
- если `wallet.type = root.key`, то `publicKeyBase58` должен совпасть с `rootKey`, прочитанным из PDA;
|
- если `wallet.type = root.key`, то `publicKeyBase58` должен совпасть с `rootKey`, прочитанным из PDA;
|
||||||
- если `wallet.type = custom`, такой проверки по PDA в первой версии нет.
|
- если `wallet.type = custom`, такой проверки по PDA пока нет.
|
||||||
|
|
||||||
При несовпадении для `client.key` или `root.key` расширение должно показать пользователю предупреждение, что возвращённый ключ не совпал с ожидаемым ключом из PDA.
|
При несовпадении для `client.key` или `root.key` расширение должно показать пользователю предупреждение, что возвращённый ключ не совпал с ожидаемым ключом из PDA.
|
||||||
|
|
||||||
@ -308,4 +308,4 @@ ESP32:
|
|||||||
- выбор типа кошелька делается только на самом ESP32;
|
- выбор типа кошелька делается только на самом ESP32;
|
||||||
- отдельная цифровая подпись ответа пока не используется;
|
- отдельная цифровая подпись ответа пока не используется;
|
||||||
- отдельное E2E-шифрование wallet RPC пока не используется;
|
- отдельное E2E-шифрование wallet RPC пока не используется;
|
||||||
- `custom`-кошельки в первой версии не сверяются с PDA.
|
- `custom`-кошельки пока не сверяются с PDA.
|
||||||
|
|||||||
@ -285,6 +285,17 @@ struct ActiveWalletSignRequest {
|
|||||||
Screen returnScreen = SCREEN_HOME;
|
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_disp_draw_buf_t gDrawBuf;
|
||||||
static lv_color_t *gBuf1 = nullptr;
|
static lv_color_t *gBuf1 = nullptr;
|
||||||
static lv_color_t *gBuf2 = nullptr;
|
static lv_color_t *gBuf2 = nullptr;
|
||||||
@ -415,6 +426,7 @@ static String gCustomWalletName;
|
|||||||
static String gCustomWalletPubB58;
|
static String gCustomWalletPubB58;
|
||||||
static String gCustomWalletPrivB58;
|
static String gCustomWalletPrivB58;
|
||||||
static std::vector<PendingWalletRpcRequest> gPendingWalletRpcRequests;
|
static std::vector<PendingWalletRpcRequest> gPendingWalletRpcRequests;
|
||||||
|
static std::vector<PendingRemoteAddBlockRequest> gPendingRemoteAddBlockRequests;
|
||||||
static const int kWalletRpcSignalTypeRequest = 9100;
|
static const int kWalletRpcSignalTypeRequest = 9100;
|
||||||
static const int kWalletRpcSignalTypeResponse = 9101;
|
static const int kWalletRpcSignalTypeResponse = 9101;
|
||||||
static ActiveWalletSignRequest gActiveWalletSignRequest;
|
static ActiveWalletSignRequest gActiveWalletSignRequest;
|
||||||
@ -477,7 +489,9 @@ static void refreshSelectedWalletBalanceState();
|
|||||||
static bool loadSelectedWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut);
|
static bool loadSelectedWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut);
|
||||||
static bool loadBalanceLamportsForAddress(const String &address, uint64_t &lamportsOut, String &messageOut);
|
static bool loadBalanceLamportsForAddress(const String &address, uint64_t &lamportsOut, String &messageOut);
|
||||||
static bool queueIncomingWalletRpcRequest(const String &frame);
|
static bool queueIncomingWalletRpcRequest(const String &frame);
|
||||||
|
static bool queueIncomingRemoteAddBlockRequest(const String &frame);
|
||||||
static void processPendingWalletRpcRequests();
|
static void processPendingWalletRpcRequests();
|
||||||
|
static void processPendingRemoteAddBlockRequests();
|
||||||
static void pumpShineIncomingFrames(uint32_t maxFrames = 3);
|
static void pumpShineIncomingFrames(uint32_t maxFrames = 3);
|
||||||
static String loginDisplayValue();
|
static String loginDisplayValue();
|
||||||
static String homeserverDisplayValue();
|
static String homeserverDisplayValue();
|
||||||
@ -617,6 +631,22 @@ static bool sendWalletRpcResponse(const String &toLogin,
|
|||||||
const String &targetSessionId,
|
const String &targetSessionId,
|
||||||
const String &callId,
|
const String &callId,
|
||||||
const String &responseData);
|
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,
|
static void queueWalletSignRequest(const PendingWalletRpcRequest &item,
|
||||||
const String &requestId,
|
const String &requestId,
|
||||||
const String &publicKeyBase58,
|
const String &publicKeyBase58,
|
||||||
@ -2695,6 +2725,126 @@ static bool sendWalletRpcResponse(const String &toLogin,
|
|||||||
return shineWsRequest(gShineWs, "CallSignalToSession", req, response, SHINE_RPC_TIMEOUT_MS);
|
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,
|
static void queueWalletSignRequest(const PendingWalletRpcRequest &item,
|
||||||
const String &requestId,
|
const String &requestId,
|
||||||
const String &publicKeyBase58,
|
const String &publicKeyBase58,
|
||||||
@ -4075,6 +4225,7 @@ static bool shineWsRequest(SimpleWebSocketClient &ws, const String &op, const St
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
queueIncomingWalletRpcRequest(frame);
|
queueIncomingWalletRpcRequest(frame);
|
||||||
|
queueIncomingRemoteAddBlockRequest(frame);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -4121,6 +4272,50 @@ static bool queueIncomingWalletRpcRequest(const String &frame) {
|
|||||||
return true;
|
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() {
|
static void processPendingWalletRpcRequests() {
|
||||||
if (gPendingWalletRpcRequests.empty() || !gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) {
|
if (gPendingWalletRpcRequests.empty() || !gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) {
|
||||||
return;
|
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) {
|
static void pumpShineIncomingFrames(uint32_t maxFrames) {
|
||||||
if (!gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) {
|
if (!gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) {
|
||||||
return;
|
return;
|
||||||
@ -4193,6 +4501,7 @@ static void pumpShineIncomingFrames(uint32_t maxFrames) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
queueIncomingWalletRpcRequest(frame);
|
queueIncomingWalletRpcRequest(frame);
|
||||||
|
queueIncomingRemoteAddBlockRequest(frame);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4748,6 +5057,7 @@ static void manageShineConnection() {
|
|||||||
|
|
||||||
pumpShineIncomingFrames();
|
pumpShineIncomingFrames();
|
||||||
processPendingWalletRpcRequests();
|
processPendingWalletRpcRequests();
|
||||||
|
processPendingRemoteAddBlockRequests();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void upsertKnownWifi(const String &ssid, const String &password) {
|
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_AckSessionDelivery_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_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_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_ReceiveIncomingMessage_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_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_AckSessionDelivery_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_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_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_ReceiveIncomingMessage_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_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("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
||||||
Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()),
|
Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()),
|
||||||
|
Map.entry("SendSignal", new Net_SendSignal_Handler()),
|
||||||
|
|
||||||
// --- system ---
|
// --- system ---
|
||||||
Map.entry("Ping", new Net_Ping_Handler()),
|
Map.entry("Ping", new Net_Ping_Handler()),
|
||||||
@ -274,6 +277,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
||||||
Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class),
|
Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class),
|
||||||
|
Map.entry("SendSignal", Net_SendSignal_Request.class),
|
||||||
|
|
||||||
// --- system ---
|
// --- system ---
|
||||||
Map.entry("Ping", Net_Ping_Request.class),
|
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
|
client.version=1.2.283
|
||||||
server.version=1.2.262
|
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 developerSettingsView from './pages/developer-settings-view.js';
|
||||||
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
|
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
|
||||||
import * as toolsSettingsView from './pages/tools-settings-view.js';
|
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 deviceView from './pages/device-view.js?v=202606131435';
|
||||||
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
|
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
|
||||||
import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940';
|
import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940';
|
||||||
@ -101,6 +102,7 @@ const routes = {
|
|||||||
'developer-settings-view': developerSettingsView,
|
'developer-settings-view': developerSettingsView,
|
||||||
'server-settings-view': serverSettingsView,
|
'server-settings-view': serverSettingsView,
|
||||||
'tools-settings-view': toolsSettingsView,
|
'tools-settings-view': toolsSettingsView,
|
||||||
|
'remote-addblock-session-view': remoteAddBlockSessionView,
|
||||||
'device-view': deviceView,
|
'device-view': deviceView,
|
||||||
'connect-device-view': connectDeviceView,
|
'connect-device-view': connectDeviceView,
|
||||||
'device-pairing-view': clientPairingView,
|
'device-pairing-view': clientPairingView,
|
||||||
|
|||||||
@ -205,7 +205,7 @@ export function render({ navigate }) {
|
|||||||
<span class="field-label">Активные заявки</span>
|
<span class="field-label">Активные заявки</span>
|
||||||
<button class="ghost-btn" type="button" id="refresh-pairing-requests">Обновить заявки</button>
|
<button class="ghost-btn" type="button" id="refresh-pairing-requests">Обновить заявки</button>
|
||||||
</div>
|
</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>
|
<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.className = 'card stack';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<button class="text-btn" type="button" id="settings-device">Устройства</button>
|
<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-servers">Настройки серверов</button>
|
||||||
<button class="text-btn" type="button" id="settings-tools">Настройки инструментов ввода</button>
|
<button class="text-btn" type="button" id="settings-tools">Настройки инструментов ввода</button>
|
||||||
<button class="text-btn" type="button" id="settings-language">Язык / Language</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-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-servers').addEventListener('click', () => navigate('server-settings-view'));
|
||||||
card.querySelector('#settings-tools').addEventListener('click', () => navigate('tools-settings-view'));
|
card.querySelector('#settings-tools').addEventListener('click', () => navigate('tools-settings-view'));
|
||||||
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
|
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
|
||||||
|
|||||||
@ -178,6 +178,7 @@ export function resolveToolbarActive(pageId) {
|
|||||||
pageId === 'developer-settings-view' ||
|
pageId === 'developer-settings-view' ||
|
||||||
pageId === 'server-settings-view' ||
|
pageId === 'server-settings-view' ||
|
||||||
pageId === 'tools-settings-view' ||
|
pageId === 'tools-settings-view' ||
|
||||||
|
pageId === 'remote-addblock-session-view' ||
|
||||||
pageId === 'device-view' ||
|
pageId === 'device-view' ||
|
||||||
pageId === 'connect-device-view' ||
|
pageId === 'connect-device-view' ||
|
||||||
pageId === 'device-pairing-view' ||
|
pageId === 'device-pairing-view' ||
|
||||||
|
|||||||
@ -56,6 +56,11 @@ const CHANNEL_TYPE_GROUP = 200;
|
|||||||
const CHANNEL_TYPE_VERSION_DEFAULT = 1;
|
const CHANNEL_TYPE_VERSION_DEFAULT = 1;
|
||||||
const SESSION_TYPE_CLIENT = 1;
|
const SESSION_TYPE_CLIENT = 1;
|
||||||
const SESSION_TYPE_WALLET = 50;
|
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({
|
const CONNECTION_SUBTYPES = Object.freeze({
|
||||||
// Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11.
|
// Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11.
|
||||||
@ -108,6 +113,10 @@ function opError(op, response) {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSignalRequestId(prefix = 'signal') {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function makeClientInfo() {
|
function makeClientInfo() {
|
||||||
const ua = navigator.userAgent || 'unknown';
|
const ua = navigator.userAgent || 'unknown';
|
||||||
return ua.slice(0, 50);
|
return ua.slice(0, 50);
|
||||||
@ -137,6 +146,11 @@ function normalizeHex32(value, fallback = ZERO64) {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sha256Base64FromText(text) {
|
||||||
|
const digest = await sha256Bytes(utf8Bytes(String(text || '')));
|
||||||
|
return bytesToBase64(digest);
|
||||||
|
}
|
||||||
|
|
||||||
function concatBytes(...chunks) {
|
function concatBytes(...chunks) {
|
||||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||||
const out = new Uint8Array(total);
|
const out = new Uint8Array(total);
|
||||||
@ -745,6 +759,9 @@ export class AuthService {
|
|||||||
this.writeLocks = new Map();
|
this.writeLocks = new Map();
|
||||||
this.passwordKeyBundleCache = new Map();
|
this.passwordKeyBundleCache = new Map();
|
||||||
this.passwordKeyBundleInFlight = new Map();
|
this.passwordKeyBundleInFlight = new Map();
|
||||||
|
this.currentLogin = '';
|
||||||
|
this.currentSessionId = '';
|
||||||
|
this.remoteAddBlockSessionId = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async reconnect(serverUrl) {
|
async reconnect(serverUrl) {
|
||||||
@ -757,6 +774,20 @@ export class AuthService {
|
|||||||
this.writeLocks.clear();
|
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) {
|
runWriteLocked(lockKey, runAction) {
|
||||||
const key = String(lockKey || '').trim() || 'write';
|
const key = String(lockKey || '').trim() || 'write';
|
||||||
if (this.writeLocks.has(key)) return this.writeLocks.get(key);
|
if (this.writeLocks.has(key)) return this.writeLocks.get(key);
|
||||||
@ -1096,6 +1127,100 @@ export class AuthService {
|
|||||||
return response?.payload?.sessions || [];
|
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) {
|
async closeSession(sessionId) {
|
||||||
const response = await this.ws.request('CloseActiveSession', { sessionId });
|
const response = await this.ws.request('CloseActiveSession', { sessionId });
|
||||||
if (response.status !== 200) throw opError('CloseActiveSession', response);
|
if (response.status !== 200) throw opError('CloseActiveSession', response);
|
||||||
@ -1219,12 +1344,8 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
async resolveFreshBlockchainCursor(login) {
|
||||||
const cleanLogin = (login || '').trim();
|
const cleanLogin = String(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 user = await this.getUser(cleanLogin);
|
||||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||||
const freshNum = Number(user?.serverLastGlobalNumber);
|
const freshNum = Number(user?.serverLastGlobalNumber);
|
||||||
@ -1236,39 +1357,102 @@ export class AuthService {
|
|||||||
serverLastGlobalHash: freshHash,
|
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 savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||||
|
const blockchainPrivatePkcs8 = String(savedKeys?.blockchainKey || '').trim();
|
||||||
|
if (blockchainPrivatePkcs8) {
|
||||||
const privateKey = await importPkcs8Ed25519(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 tryAdd = async (cursor) => {
|
||||||
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
|
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
|
||||||
const prevBlockHash = normalizeHex32(cursor?.serverLastGlobalHash, ZERO64);
|
const prevBlockHash = normalizeHex32(cursor?.serverLastGlobalHash, ZERO64);
|
||||||
const preimage = buildBlockPreimage({
|
const preimage = await buildPreimage({ blockNumber, prevBlockHash, blockchainName });
|
||||||
prevBlockHashHex: prevBlockHash,
|
return this.submitPreparedAddBlock({
|
||||||
blockNumber,
|
login,
|
||||||
msgType,
|
storagePwd,
|
||||||
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', {
|
|
||||||
blockchainName,
|
blockchainName,
|
||||||
blockNumber,
|
blockNumber,
|
||||||
prevBlockHash,
|
prevBlockHash,
|
||||||
blockBytesB64: bytesToBase64(fullBlock),
|
preimage,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1281,13 +1465,38 @@ export class AuthService {
|
|||||||
cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash.toLowerCase() };
|
cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash.toLowerCase() };
|
||||||
response = await tryAdd(cursor);
|
response = await tryAdd(cursor);
|
||||||
} else {
|
} else {
|
||||||
const refreshed = await resolveFreshCursor();
|
freshState = await resolveFreshState();
|
||||||
cursor = refreshed.cursor;
|
blockchainName = String(freshState?.blockchainName || blockchainName).trim() || blockchainName;
|
||||||
|
cursor = freshState.cursor;
|
||||||
response = await tryAdd(cursor);
|
response = await tryAdd(cursor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status !== 200) throw opError('AddBlock', response);
|
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 payload = response.payload || {};
|
||||||
const acceptedNum = Number(payload?.serverLastGlobalNumber);
|
const acceptedNum = Number(payload?.serverLastGlobalNumber);
|
||||||
@ -2249,31 +2458,11 @@ export class AuthService {
|
|||||||
if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param.');
|
if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param.');
|
||||||
if (!cleanValue) throw new Error('Значение параметра не может быть пустым.');
|
if (!cleanValue) throw new Error('Значение параметра не может быть пустым.');
|
||||||
if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.');
|
if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.');
|
||||||
|
const { response } = await this.runAddBlockWithRetry({
|
||||||
const user = await this.getUser(cleanLogin);
|
login: cleanLogin,
|
||||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
storagePwd,
|
||||||
const freshNum = Number(user?.serverLastGlobalNumber);
|
resolveFreshState: () => this.resolveFreshBlockchainCursor(cleanLogin),
|
||||||
const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase();
|
buildPreimage: async ({ blockNumber, prevBlockHash }) => {
|
||||||
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({
|
const bodyBytes = makeUserParamBodyBytes({
|
||||||
lineCode: 0,
|
lineCode: 0,
|
||||||
prevLineNumber: -1,
|
prevLineNumber: -1,
|
||||||
@ -2282,8 +2471,7 @@ export class AuthService {
|
|||||||
key: cleanParam,
|
key: cleanParam,
|
||||||
value: cleanValue,
|
value: cleanValue,
|
||||||
});
|
});
|
||||||
|
return concatBytes(
|
||||||
const preimage = concatBytes(
|
|
||||||
int16Bytes(0),
|
int16Bytes(0),
|
||||||
hexToBytes(prevBlockHash),
|
hexToBytes(prevBlockHash),
|
||||||
int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
|
int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
|
||||||
@ -2294,33 +2482,8 @@ export class AuthService {
|
|||||||
int16Bytes(1),
|
int16Bytes(1),
|
||||||
bodyBytes,
|
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);
|
|
||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2337,32 +2500,15 @@ export class AuthService {
|
|||||||
|
|
||||||
const user = await this.getUser(cleanLogin);
|
const user = await this.getUser(cleanLogin);
|
||||||
if (user?.exists === false) throw new Error('Текущий пользователь не найден.');
|
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);
|
const targetUser = await this.getUser(cleanToLogin);
|
||||||
if (!targetUser?.exists) throw new Error('Пользователь цели не найден.');
|
if (!targetUser?.exists) throw new Error('Пользователь цели не найден.');
|
||||||
const toBlockchainName = String(targetUser?.blockchainName || `${cleanToLogin}-${BCH_SUFFIX}`).trim();
|
const toBlockchainName = String(targetUser?.blockchainName || `${cleanToLogin}-${BCH_SUFFIX}`).trim();
|
||||||
|
const { response } = await this.runAddBlockWithRetry({
|
||||||
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
login: cleanLogin,
|
||||||
const blockchainPrivatePkcs8 = savedKeys?.blockchainKey;
|
storagePwd,
|
||||||
if (!blockchainPrivatePkcs8) {
|
resolveFreshState: () => this.resolveFreshBlockchainCursor(cleanLogin),
|
||||||
throw new Error('На устройстве нет сохраненного приватного blockchainKey');
|
buildPreimage: async ({ blockNumber, prevBlockHash }) => {
|
||||||
}
|
|
||||||
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({
|
const bodyBytes = makeConnectionBodyBytes({
|
||||||
lineCode: 0,
|
lineCode: 0,
|
||||||
prevLineNumber: -1,
|
prevLineNumber: -1,
|
||||||
@ -2372,8 +2518,7 @@ export class AuthService {
|
|||||||
toBlockNumber: 0,
|
toBlockNumber: 0,
|
||||||
toBlockHashHex: ZERO_HASH_HEX,
|
toBlockHashHex: ZERO_HASH_HEX,
|
||||||
});
|
});
|
||||||
|
return concatBytes(
|
||||||
const preimage = concatBytes(
|
|
||||||
int16Bytes(0),
|
int16Bytes(0),
|
||||||
hexToBytes(prevBlockHash),
|
hexToBytes(prevBlockHash),
|
||||||
int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
|
int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
|
||||||
@ -2384,31 +2529,8 @@ export class AuthService {
|
|||||||
int16Bytes(1),
|
int16Bytes(1),
|
||||||
bodyBytes,
|
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);
|
|
||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -188,6 +188,7 @@ function persistEntrySettings(settings) {
|
|||||||
shineServerHttp: String(settings?.shineServerHttp || DEFAULT_SHINE_SERVER_HTTP_VALUE),
|
shineServerHttp: String(settings?.shineServerHttp || DEFAULT_SHINE_SERVER_HTTP_VALUE),
|
||||||
arweaveServer: String(settings?.arweaveServer || DEFAULT_ARWEAVE_SERVER),
|
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)),
|
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: {
|
statuses: {
|
||||||
solanaServer: String(settings?.statuses?.solanaServer || 'idle'),
|
solanaServer: String(settings?.statuses?.solanaServer || 'idle'),
|
||||||
shineServerLogin: String(settings?.statuses?.shineServerLogin || settings?.statuses?.shineServer || '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),
|
shineServerHttp: String(storedEntrySettings?.shineServerHttp || DEFAULT_SHINE_SERVER_HTTP_VALUE),
|
||||||
arweaveServer: String(storedEntrySettings?.arweaveServer || DEFAULT_ARWEAVE_SERVER),
|
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)),
|
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: {
|
statuses: {
|
||||||
solanaServer: String(storedEntrySettings?.statuses?.solanaServer || 'idle'),
|
solanaServer: String(storedEntrySettings?.statuses?.solanaServer || 'idle'),
|
||||||
shineServerLogin: String(storedEntrySettings?.statuses?.shineServerLogin || storedEntrySettings?.statuses?.shineServer || 'idle'),
|
shineServerLogin: String(storedEntrySettings?.statuses?.shineServerLogin || storedEntrySettings?.statuses?.shineServer || 'idle'),
|
||||||
@ -316,6 +318,11 @@ function createInitialState({ withStoredSession = true } = {}) {
|
|||||||
export const state = createInitialState();
|
export const state = createInitialState();
|
||||||
|
|
||||||
export const authService = new AuthService(state.entrySettings.shineServer);
|
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 onSessionReset = null;
|
||||||
let onSessionAuthorized = null;
|
let onSessionAuthorized = null;
|
||||||
|
|
||||||
@ -738,6 +745,7 @@ export async function saveEntrySettings(nextSettings) {
|
|||||||
tools: normalizeToolsSettings(nextSettings.tools || state.entrySettings.tools),
|
tools: normalizeToolsSettings(nextSettings.tools || state.entrySettings.tools),
|
||||||
};
|
};
|
||||||
persistEntrySettings(state.entrySettings);
|
persistEntrySettings(state.entrySettings);
|
||||||
|
authService.setRemoteAddBlockSessionId(state.entrySettings.remoteAddBlockSessionId);
|
||||||
await authService.reconnect(state.entrySettings.shineServer);
|
await authService.reconnect(state.entrySettings.shineServer);
|
||||||
state.startHint = `Настройки входа сохранены. SHiNE: ${state.entrySettings.shineServerHttp}`;
|
state.startHint = `Настройки входа сохранены. SHiNE: ${state.entrySettings.shineServerHttp}`;
|
||||||
}
|
}
|
||||||
@ -777,6 +785,7 @@ export function authorizeSession({
|
|||||||
login,
|
login,
|
||||||
sessionId,
|
sessionId,
|
||||||
});
|
});
|
||||||
|
authService.setActiveSessionContext({ login, sessionId });
|
||||||
state.startHint = '';
|
state.startHint = '';
|
||||||
if (onSessionAuthorized) {
|
if (onSessionAuthorized) {
|
||||||
onSessionAuthorized();
|
onSessionAuthorized();
|
||||||
@ -851,6 +860,7 @@ export async function terminateCurrentSession({ infoMessage = '', closeServerSes
|
|||||||
resetStateForSignedOut();
|
resetStateForSignedOut();
|
||||||
await clearStoredMessages().catch(() => {});
|
await clearStoredMessages().catch(() => {});
|
||||||
authService.close();
|
authService.close();
|
||||||
|
authService.clearActiveSessionContext();
|
||||||
if (infoMessage) {
|
if (infoMessage) {
|
||||||
state.startHint = infoMessage;
|
state.startHint = infoMessage;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user