Слить обновления UI каналов и кошелька

This commit is contained in:
AidarKC 2026-06-24 15:00:02 +04:00
commit 127c561a41
168 changed files with 2763 additions and 31082 deletions

View File

@ -134,7 +134,7 @@
Что сделать: Что сделать:
- продумать и реализовать смену `root key`, `device key`, `blockchain key`; - продумать и реализовать смену `root key`, `client key`, `blockchain key`;
- описать ограничения, кто и в каком сценарии может менять каждый тип ключа; - описать ограничения, кто и в каком сценарии может менять каждый тип ключа;
- продумать, как не потерять доступ и как обновлять доверие к новым ключам. - продумать, как не потерять доступ и как обновлять доверие к новым ключам.

View File

@ -8,7 +8,7 @@ shine.db.SqliteDbController — один вход в БД: читает db.path,
shine.db.DatabaseInitializer — разовая сборка схемы (таблицы + индексы). shine.db.DatabaseInitializer — разовая сборка схемы (таблицы + индексы).
shine.db.entities.* — POJO-модели строк таблиц (без логики, только поля/геттеры/сеттеры + иногда удобные методы вроде getDeviceKeyByte()). shine.db.entities.* — POJO-модели строк таблиц (без логики, только поля/геттеры/сеттеры + иногда удобные методы вроде getClientKeyByte()).
shine.db.dao.* — DAO по таблицам: ActiveSessionsDAO, SolanaUsersDAO, UserParamsDAO, IpGeoCacheDAO, BlockchainStateDAO, BlocksDAO; плюс “сервисные” DAO: shine.db.dao.* — DAO по таблицам: ActiveSessionsDAO, SolanaUsersDAO, UserParamsDAO, IpGeoCacheDAO, BlockchainStateDAO, BlocksDAO; плюс “сервисные” DAO:
UserCreateDAO — атомарная регистрация пользователя в транзакции (BEGIN IMMEDIATE + rollback/commit). UserCreateDAO — атомарная регистрация пользователя в транзакции (BEGIN IMMEDIATE + rollback/commit).

View File

@ -38,7 +38,7 @@ message_stats ⭐
solana_users solana_users
login — TEXT PK — уникальный логин пользователя login — TEXT PK — уникальный логин пользователя
device_key — TEXT NOT NULL — публичный ключ устройства (Base64(32) / HEX(64)) client_key — TEXT NOT NULL — публичный ключ устройства (Base64(32) / HEX(64))
solana_key — TEXT NULL — публичный ключ Solana-аккаунта solana_key — TEXT NULL — публичный ключ Solana-аккаунта
active_sessions active_sessions
@ -61,7 +61,7 @@ login — TEXT NOT NULL, FK → solana_users(login)
param — TEXT NOT NULL param — TEXT NOT NULL
time_ms — INTEGER NOT NULL time_ms — INTEGER NOT NULL
value — TEXT NOT NULL value — TEXT NOT NULL
device_key — TEXT NULL client_key — TEXT NULL
signature — TEXT NULL signature — TEXT NULL
Ограничение: Ограничение:

View File

@ -35,7 +35,7 @@
"blockchainName": "anya-001", "blockchainName": "anya-001",
"solanaKey": "BASE64_32_PUBLIC_KEY", "solanaKey": "BASE64_32_PUBLIC_KEY",
"blockchainKey": "BASE64_32_PUBLIC_KEY", "blockchainKey": "BASE64_32_PUBLIC_KEY",
"deviceKey": "BASE64_32_PUBLIC_KEY", "clientKey": "BASE64_32_PUBLIC_KEY",
"bchLimit": 1000000 "bchLimit": 1000000
} }
} }
@ -99,7 +99,7 @@
"blockchainName": "anya-001", "blockchainName": "anya-001",
"solanaKey": "BASE64_32_PUBLIC_KEY", "solanaKey": "BASE64_32_PUBLIC_KEY",
"blockchainKey": "BASE64_32_PUBLIC_KEY", "blockchainKey": "BASE64_32_PUBLIC_KEY",
"deviceKey": "BASE64_32_PUBLIC_KEY", "clientKey": "BASE64_32_PUBLIC_KEY",
"serverLastGlobalNumber": 128, "serverLastGlobalNumber": 128,
"serverLastGlobalHash": "4f...ab", "serverLastGlobalHash": "4f...ab",
"serverBlockchainSizeBytes": 45212, "serverBlockchainSizeBytes": 45212,

View File

@ -11,7 +11,7 @@
Логика раздела такая: Логика раздела такая:
- сначала клиент либо начинает создание новой сессии через `deviceKey`; - сначала клиент либо начинает создание новой сессии через `clientKey`;
- либо начинает вход в уже созданную сессию через `sessionKey`; - либо начинает вход в уже созданную сессию через `sessionKey`;
- сервер на первом шаге выдаёт challenge/nonce; - сервер на первом шаге выдаёт challenge/nonce;
- на втором шаге клиент присылает подписанный ответ; - на втором шаге клиент присылает подписанный ответ;
@ -55,7 +55,7 @@
2. Вход в существующую сессию: 2. Вход в существующую сессию:
`SessionChallenge` -> `SessionLogin` `SessionChallenge` -> `SessionLogin`
`deviceKey` используется для создания новой сессии. `clientKey` используется для создания новой сессии.
`sessionKey` используется для входа в уже созданную сессию. `sessionKey` используется для входа в уже созданную сессию.
@ -119,7 +119,7 @@ ed25519/BASE64_PUBLIC_KEY
"storagePwd": "BASE64_OR_APP_SPECIFIC_SECRET", "storagePwd": "BASE64_OR_APP_SPECIFIC_SECRET",
"timeMs": 1774600000123, "timeMs": 1774600000123,
"authNonce": "nonce", "authNonce": "nonce",
"deviceKey": "BASE64_DEVICE_PUBLIC_KEY", "clientKey": "BASE64_DEVICE_PUBLIC_KEY",
"signatureB64": "BASE64_SIGNATURE", "signatureB64": "BASE64_SIGNATURE",
"sessionType": 1, "sessionType": 1,
"clientPlatform": "Web", "clientPlatform": "Web",
@ -138,15 +138,15 @@ AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
Перед проверкой подписи сервер должен: Перед проверкой подписи сервер должен:
1. взять актуальный `solana_users.device_key`; 1. взять актуальный `solana_users.client_key`;
2. сравнить его с `payload.deviceKey`; 2. сравнить его с `payload.clientKey`;
3. только потом проверять подпись. 3. только потом проверять подпись.
Если ключ не совпадает, сервер возвращает ошибку `DEVICE_KEY_NOT_ACTUAL`. Если `clientKey` не совпадает, сервер возвращает ошибку `DEVICE_KEY_NOT_ACTUAL`.
На будущее: На будущее:
- для ротации `device_key` желательно добавить перепроверку через Solana. - для ротации `client_key` желательно добавить перепроверку через Solana.
### Успешный ответ ### Успешный ответ
@ -172,15 +172,15 @@ AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
- `501 / NO_LOGIN`у пользователя на сервере не заполнен `login`. - `501 / NO_LOGIN`у пользователя на сервере не заполнен `login`.
- `400 / EMPTY_STORAGE_PWD` — пустой `storagePwd`. - `400 / EMPTY_STORAGE_PWD` — пустой `storagePwd`.
- `400 / EMPTY_SESSION_KEY` — пустой `sessionKey`. - `400 / EMPTY_SESSION_KEY` — пустой `sessionKey`.
- `422 / UNSUPPORTED_KEY_ALGORITHM` — префикс алгоритма в `sessionKey` или `deviceKey` не поддерживается текущим сервером. - `422 / UNSUPPORTED_KEY_ALGORITHM` — префикс алгоритма в `sessionKey` или `clientKey` не поддерживается текущим сервером.
- `400 / BAD_BASE64` — неверный Base64 в `sessionKey`, `deviceKey` или `signatureB64`. - `400 / BAD_BASE64` — неверный Base64 в `sessionKey`, `clientKey` или `signatureB64`.
- `400 / EMPTY_SIGNATURE` — пустая подпись. - `400 / EMPTY_SIGNATURE` — пустая подпись.
- `400 / TIME_SKEW` — время клиента отличается от серверного больше допустимого окна. - `400 / TIME_SKEW` — время клиента отличается от серверного больше допустимого окна.
- `400 / NO_DEVICE_KEY`у пользователя в БД отсутствует `deviceKey`. - `400 / NO_DEVICE_KEY`у пользователя в БД отсутствует `clientKey`.
- `400 / EMPTY_AUTH_NONCE` — пустой `authNonce`. - `400 / EMPTY_AUTH_NONCE` — пустой `authNonce`.
- `400 / AUTH_NONCE_MISMATCH``authNonce` не соответствует значению из `AuthChallenge`. - `400 / AUTH_NONCE_MISMATCH``authNonce` не соответствует значению из `AuthChallenge`.
- `400 / EMPTY_DEVICE_KEY` — в запросе не передан `deviceKey`. - `400 / EMPTY_DEVICE_KEY` — в запросе не передан `clientKey`.
- `422 / DEVICE_KEY_NOT_ACTUAL``deviceKey` не совпадает с актуальной версией на сервере. - `422 / DEVICE_KEY_NOT_ACTUAL``clientKey` не совпадает с актуальной версией на сервере.
- `422 / BAD_SIGNATURE` — подпись не прошла проверку. - `422 / BAD_SIGNATURE` — подпись не прошла проверку.
- `460 / SESSION_TYPE_MISMATCH``sessionType` не совпадает с типом сессии, уже опубликованным для этого `sessionKey` в Solana PDA. - `460 / SESSION_TYPE_MISMATCH``sessionType` не совпадает с типом сессии, уже опубликованным для этого `sessionKey` в Solana PDA.
- `501 / SESSION_TYPE_PDA_CHECK_FAILED` — сервер не смог проверить `sessionType` по Solana PDA. - `501 / SESSION_TYPE_PDA_CHECK_FAILED` — сервер не смог проверить `sessionType` по Solana PDA.
@ -314,7 +314,7 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
В этом потоке: В этом потоке:
- новое устройство не владеет `deviceKey` и не проходит обычный `CreateAuthSession`; - новое устройство не владеет `clientKey` и не проходит обычный `CreateAuthSession`;
- пароль проверяется сервером только как фильтр; - пароль проверяется сервером только как фильтр;
- решение об одобрении принимает уже авторизованная доверенная сессия пользователя; - решение об одобрении принимает уже авторизованная доверенная сессия пользователя;
- сервер не расшифровывает `encryptedPayload` и не становится источником приватных ключей. - сервер не расшифровывает `encryptedPayload` и не становится источником приватных ключей.

View File

@ -21,7 +21,7 @@
"param": "display_name", "param": "display_name",
"time_ms": 1774700000123, "time_ms": 1774700000123,
"value": "Alice", "value": "Alice",
"device_key": "BASE64_DEVICE_PUBLIC_KEY", "client_key": "BASE64_DEVICE_PUBLIC_KEY",
"signature": "BASE64_SIGNATURE" "signature": "BASE64_SIGNATURE"
} }
} }
@ -76,7 +76,7 @@
"param": "display_name", "param": "display_name",
"time_ms": 1774700000123, "time_ms": 1774700000123,
"value": "Alice", "value": "Alice",
"device_key": "BASE64_DEVICE_PUBLIC_KEY", "client_key": "BASE64_DEVICE_PUBLIC_KEY",
"signature": "BASE64_SIGNATURE" "signature": "BASE64_SIGNATURE"
} }
} }
@ -116,7 +116,7 @@
"param": "display_name", "param": "display_name",
"time_ms": 1774700000123, "time_ms": 1774700000123,
"value": "Alice", "value": "Alice",
"device_key": "BASE64_DEVICE_PUBLIC_KEY", "client_key": "BASE64_DEVICE_PUBLIC_KEY",
"signature": "BASE64_SIGNATURE" "signature": "BASE64_SIGNATURE"
} }
] ]
@ -126,4 +126,4 @@
## Примечание ## Примечание
Имена JSON-полей `time_ms` и `device_key` сейчас соответствуют Java-модели ответа/запроса и должны передаваться именно в таком виде. Имена JSON-полей `time_ms` и `client_key` сейчас соответствуют Java-модели ответа/запроса и должны передаваться именно в таком виде.

View File

@ -22,7 +22,7 @@ ESP32 становится аппаратным HSM (hardware security module):
### ESP32 (основная работа) ### ESP32 (основная работа)
- [ ] Инициализация WiFi (SSID/пароль в NVS) - [ ] Инициализация WiFi (SSID/пароль в NVS)
- [ ] WebSocket-клиент (`WebSocketsClient`) — постоянное соединение с сервером - [ ] WebSocket-клиент (`WebSocketsClient`) — постоянное соединение с сервером
- [ ] Авторизация на сервере: `AuthChallenge``CreateAuthSession` через `deviceKey` (уже есть в NVS), сохранить `sessionId` в NVS - [ ] Авторизация на сервере: `AuthChallenge``CreateAuthSession` через `clientKey` (уже есть в NVS), сохранить `sessionId` в NVS
- [ ] Обработчик входящих WebSocket-событий: JSON-парсинг, диспетчер по типу - [ ] Обработчик входящих WebSocket-событий: JSON-парсинг, диспетчер по типу
- [ ] Новые UI-экраны: «Разрешить сессию?» и «Подписать?» с кнопками Да/Нет - [ ] Новые UI-экраны: «Разрешить сессию?» и «Подписать?» с кнопками Да/Нет
- [ ] Расширенное хранилище ключей в NVS (произвольные именованные ключи сверх базовых трёх) - [ ] Расширенное хранилище ключей в NVS (произвольные именованные ключи сверх базовых трёх)

View File

@ -56,7 +56,7 @@ seed(32) = SHA-256(material)
|------|---------|---------------------| |------|---------|---------------------|
| root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). | | root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). |
| blockchain | `bch.key` | Подписывает `LastBlockState` персонального блокчейна (`blockchain_public_key`). | | blockchain | `bch.key` | Подписывает `LastBlockState` персонального блокчейна (`blockchain_public_key`). |
| device / **Solana** | `dev.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(devicePub)`. См. §3. | | device / **Solana** | `client.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(clientPub)`. См. §3. |
| homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. | | homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. |
Полные роли каждого ключа — в `Dev_Docs/Keys/README.md`. Полные роли каждого ключа — в `Dev_Docs/Keys/README.md`.
@ -67,16 +67,16 @@ seed(32) = SHA-256(material)
Отдельного «солана-ключа» нет. На Solana работают два ключа: Отдельного «солана-ключа» нет. На Solana работают два ключа:
- **`dev.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(devicePub)`. - **`client.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(clientPub)`.
Этим ключом оплачиваются и подписываются `create_user_pda` / `update_user_pda`. Этим ключом оплачиваются и подписываются `create_user_pda` / `update_user_pda`.
Пополнять SOL нужно именно на этот адрес. Пополнять SOL нужно именно на этот адрес.
- **`root.key` — авторитет записи**, подписывает unsigned-часть PDA через Ed25519-инструкцию, но **не** является fee payer. - **`root.key` — авторитет записи**, подписывает unsigned-часть PDA через Ed25519-инструкцию, но **не** является fee payer.
Соответствует формату PDA `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` §2.1 Соответствует формату PDA `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` §2.1
(«create/update оплачиваются с `device_key`», «root_key — не fee payer»). («create/update оплачиваются с `client_key`», «root_key — не fee payer»).
Кратко про роли на Solana: `root.key` — это **главный (master) ключ**: им управляют PDA-записью Кратко про роли на Solana: `root.key` — это **главный (master) ключ**: им управляют PDA-записью
(`create/update`) и через это можно заменить все остальные ключи; `dev.key` — это **пополняемый (`create/update`) и через это можно заменить все остальные ключи; `client.key` — это **пополняемый
кошелёк и плательщик комиссий**. Полное описание ролей — `Dev_Docs/Keys/README.md`. кошелёк и плательщик комиссий**. Полное описание ролей — `Dev_Docs/Keys/README.md`.
--- ---
@ -118,9 +118,9 @@ homeserver.key:home-b -> ключ B
- `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147160). - `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147160).
### Solana-ключ / адрес кошелька (UI) ### Solana-ключ / адрес кошелька (UI)
- `shine-UI/js/pages/registration-payment-view.js``deriveUserWalletAddress`: адрес = `base58(devicePub)` (~113). - `shine-UI/js/pages/registration-payment-view.js``deriveUserWalletAddress`: адрес = `base58(clientPub)` (~113).
- `shine-UI/js/pages/topup-view.js``deviceWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.devicePair`. - `shine-UI/js/pages/topup-view.js``clientWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.clientPair`.
Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `dev.key`, мимо `masterSecret`) удалён. Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `client.key`, мимо `masterSecret`) удалён.
### Деривация ключей (прошивка ESP32) ### Деривация ключей (прошивка ESP32)
- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino` - `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino`
@ -131,7 +131,7 @@ homeserver.key:home-b -> ключ B
### Формат PDA (куда попадают ключи) ### Формат PDA (куда попадают ключи)
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` - `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
`RootKeyBlock` §6, `DeviceKeyBlock` §7, `blockchain_public_key` §9, `SessionsBlock`/`session_type=100` §13, оплата §2.1. `RootKeyBlock` §6, `ClientKeyBlock` §7, `blockchain_public_key` §9, `SessionsBlock`/`session_type=100` §13, оплата §2.1.
### Сервер (тестовый seed) ### Сервер (тестовый seed)
- `SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java` `deriveKeysFromPassword` (~246) — - `SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java` `deriveKeysFromPassword` (~246) —

View File

@ -8,9 +8,9 @@
В SHiNE у пользователя есть несколько уровней ключей: В SHiNE у пользователя есть несколько уровней ключей:
- `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `device key`). - `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `client key`).
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя. - `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя.
- `device key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей. - `client key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей.
- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере. - `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере.
Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы. Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы.
@ -30,7 +30,7 @@
`root key` — это **главный (master) ключ** в следующем смысле: зная `root key`, можно управлять пользовательской PDA-записью в Solana (`create_user_pda` / `update_user_pda`) и тем самым **заменить все остальные ключи** пользователя (device, blockchain, homeserver). Поэтому компрометация `root key` равносильна компрометации всей личности пользователя. `root key` — это **главный (master) ключ** в следующем смысле: зная `root key`, можно управлять пользовательской PDA-записью в Solana (`create_user_pda` / `update_user_pda`) и тем самым **заменить все остальные ключи** пользователя (device, blockchain, homeserver). Поэтому компрометация `root key` равносильна компрометации всей личности пользователя.
Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `device key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `Dev_Docs/Keys/DERIVATION.md`, §3. Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `client key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `Dev_Docs/Keys/DERIVATION.md`, §3.
## `blockchain key` ## `blockchain key`
@ -50,9 +50,9 @@
Рабочая логика по умолчанию должна использовать последнюю актуальную ветку. Старые ветки остаются читаемыми и показывают историю смены ключей. Рабочая логика по умолчанию должна использовать последнюю актуальную ветку. Старые ветки остаются читаемыми и показывают историю смены ключей.
## `device key` ## `client key`
`device key` - общий ключ, который знают доверенные устройства пользователя. `client key` - общий ключ, который знают доверенные устройства пользователя.
Назначение: Назначение:
@ -63,11 +63,11 @@
- derivation Arweave-кошелька; - derivation Arweave-кошелька;
- оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу. - оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу.
Arweave-кошелёк должен выводиться из `device key` по протоколу: Arweave-кошелёк должен выводиться из `client key` по протоколу:
- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` - `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md`
Если пользователь теряет только `device key`, в худшем случае ломается повседневная переписка и доступ конкретных устройств к ежедневным операциям. `root key` и `blockchain key` при правильной архитектуре остаются отдельно защищёнными. Если пользователь теряет только `client key`, в худшем случае ломается повседневная переписка и доступ конкретных устройств к ежедневным операциям. `root key` и `blockchain key` при правильной архитектуре остаются отдельно защищёнными.
## `session key` ## `session key`
@ -83,7 +83,7 @@ Arweave-кошелёк должен выводиться из `device key` по
- авторизация сессии на сервере; - авторизация сессии на сервере;
- привязка устройства к пользователю; - привязка устройства к пользователю;
- подтверждение запросов от конкретной сессии; - подтверждение запросов от конкретной сессии;
- доступ к зашифрованному `device key` после успешной авторизации. - доступ к зашифрованному `client key` после успешной авторизации.
Одна и та же сессия может быть пригодна для подключения к нескольким серверам пользователя, если архитектура конкретного пользователя это допускает. Одна и та же сессия может быть пригодна для подключения к нескольким серверам пользователя, если архитектура конкретного пользователя это допускает.
@ -108,14 +108,14 @@ Arweave-кошелёк должен выводиться из `device key` по
Обычное устройство обычно имеет: Обычное устройство обычно имеет:
- собственный `session key`; - собственный `session key`;
- зашифрованный `device key`, который открывается после авторизации; - зашифрованный `client key`, который открывается после авторизации;
- доступ к DM, звонкам и обычным пользовательским операциям. - доступ к DM, звонкам и обычным пользовательским операциям.
Доверенное серверное или аппаратное устройство может иметь: Доверенное серверное или аппаратное устройство может иметь:
- `root key`; - `root key`;
- `blockchain key`; - `blockchain key`;
- `device key`; - `client key`;
- собственный `session key`. - собственный `session key`.
Такая сессия может подписывать операции повышенной важности по запросам пользователя. Такая сессия может подписывать операции повышенной важности по запросам пользователя.
@ -139,7 +139,7 @@ Self-message - это сообщение пользователя самому
Входящее сообщение может быть зашифровано: Входящее сообщение может быть зашифровано:
- `device key`; - `client key`;
- `session key`; - `session key`;
- отдельным ключом конкретного чата; - отдельным ключом конкретного чата;
- другим ключом, который уже известен клиенту. - другим ключом, который уже известен клиенту.
@ -158,12 +158,12 @@ Self-message - это сообщение пользователя самому
## Связанные документы ## Связанные документы
- `Dev_Docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`dev.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код). - `Dev_Docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`client.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код).
- `Dev_Docs/Personal_Messages/README.md` - текущая документация личных сообщений. - `Dev_Docs/Personal_Messages/README.md` - текущая документация личных сообщений.
- `Dev_Docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна. - `Dev_Docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна.
- `Dev_Docs/Solana_Architecture/README.md` - архитектура Solana-программ, PDA-счетов, DAO и движения средств. - `Dev_Docs/Solana_Architecture/README.md` - архитектура Solana-программ, PDA-счетов, DAO и движения средств.
- `Dev_Docs/Инициализация_Solana_регистрации/README.md` - деплой и первичная инициализация Solana-регистрации. - `Dev_Docs/Инициализация_Solana_регистрации/README.md` - деплой и первичная инициализация Solana-регистрации.
- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` - derivation Arweave-кошелька из `device key`. - `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` - derivation Arweave-кошелька из `client key`.
## Что нужно уточнить перед реализацией ## Что нужно уточнить перед реализацией
@ -172,5 +172,5 @@ Self-message - это сообщение пользователя самому
- какие операции требуют `root key`, а какие достаточно подписывать `blockchain key`; - какие операции требуют `root key`, а какие достаточно подписывать `blockchain key`;
- формат self-message-команд; - формат self-message-команд;
- порядок перебора ключей при расшифровке входящих сообщений; - порядок перебора ключей при расшифровке входящих сообщений;
- правила ротации `device key` и восстановления доступа после потери устройства; - правила ротации `client key` и восстановления доступа после потери устройства;
- какие типы серверных и аппаратных сессий нужны в первой реализации. - какие типы серверных и аппаратных сессий нужны в первой реализации.

View File

@ -11,7 +11,7 @@
- на ESP32 после успешной homeserver-авторизации в `SETTINGS` появляется пункт `Device requests` первым; - на ESP32 после успешной homeserver-авторизации в `SETTINGS` появляется пункт `Device requests` первым;
- `REFRESH` реально загружает активные заявки; - `REFRESH` реально загружает активные заявки;
- на экране видно две плитки, список листается вертикально; - на экране видно две плитки, список листается вертикально;
- client-session заявка после `YES` подключается с передачей только `device key`; - client-session заявка после `YES` подключается с передачей только `client key`;
- wallet-session заявка после `YES` подключается без передачи ключей, через выпуск отдельной wallet-session; - wallet-session заявка после `YES` подключается без передачи ключей, через выпуск отдельной wallet-session;
- `NO` отклоняет заявку и она исчезает из списка активных. - `NO` отклоняет заявку и она исчезает из списка активных.

View File

@ -2,20 +2,20 @@
- краткое описание: - краткое описание:
- в ESP32 заменён домашний блок `баланс + QR` на единый вход в экран кошелька; - в ESP32 заменён домашний блок `баланс + QR` на единый вход в экран кошелька;
- добавлен выбор активного кошелька `DeviceKey / RootKey / Custom`; - добавлен выбор активного кошелька `ClientKey / RootKey / Custom`;
- для browser extension добавлен первый RPC `get_wallet_public_key` через существующий `wallet-session` и `CallSignalToSession`; - для browser extension добавлен первый RPC `get_wallet_public_key` через существующий `wallet-session` и `CallSignalToSession`;
- в popup расширения добавлен запрос текущего кошелька и копирование `publicKeyBase58`. - в popup расширения добавлен запрос текущего кошелька и копирование `publicKeyBase58`.
- что проверять: - что проверять:
- на ESP32 после ввода секрета на главном экране видна кнопка `Кошелёк: ...`; - на ESP32 после ввода секрета на главном экране видна кнопка `Кошелёк: ...`;
- экран `WALLET` открывается и показывает текущий тип кошелька; - экран `WALLET` открывается и показывает текущий тип кошелька;
- экран `WALLET_SELECT` переключает `DeviceKey`, `RootKey` и `Custom`; - экран `WALLET_SELECT` переключает `ClientKey`, `RootKey` и `Custom`;
- для `Custom` открывается ввод имени и после сохранения derivation работает; - для `Custom` открывается ввод имени и после сохранения derivation работает;
- `Показать баланс кошелька` читает баланс именно активного кошелька; - `Показать баланс кошелька` читает баланс именно активного кошелька;
- `Показать QR-код кошелька` показывает QR и адрес именно активного кошелька; - `Показать QR-код кошелька` показывает QR и адрес именно активного кошелька;
- browser extension после подключения wallet-session может запросить текущий кошелёк у ESP32; - browser extension после подключения wallet-session может запросить текущий кошелёк у ESP32;
- extension показывает тип кошелька, полный `publicKeyBase58`, результат проверки через PDA и копирует ключ в буфер; - extension показывает тип кошелька, полный `publicKeyBase58`, результат проверки через PDA и копирует ключ в буфер;
- для `dev.key` и `root.key` проверка через PDA даёт ожидаемое совпадение. - для `client.key` и `root.key` проверка через PDA даёт ожидаемое совпадение.
- ожидаемый результат: - ожидаемый результат:
- активный кошелёк на ESP32 реально влияет на баланс, QR и ответ `get_wallet_public_key`; - активный кошелёк на ESP32 реально влияет на баланс, QR и ответ `get_wallet_public_key`;

View File

@ -5,7 +5,7 @@
- Что проверять: - Что проверять:
1. На ESP32 открыть `HOME`, `WALLET`, `SELECT WALLET`, `WALLET QR` и убедиться, что пользовательские строки на экране только на английском. 1. На ESP32 открыть `HOME`, `WALLET`, `SELECT WALLET`, `WALLET QR` и убедиться, что пользовательские строки на экране только на английском.
2. Проверить сценарий выбора `DeviceKey`, `RootKey`, `Custom` и чтение баланса/QR после переключения. 2. Проверить сценарий выбора `ClientKey`, `RootKey`, `Custom` и чтение баланса/QR после переключения.
3. В расширении открыть popup, запустить `Подключить` для логина, у которого ещё не настраивался trusted-device login. 3. В расширении открыть popup, запустить `Подключить` для логина, у которого ещё не настраивался trusted-device login.
4. Убедиться, что `StartTrustedDeviceLogin` больше не падает с `NullPointerException`. 4. Убедиться, что `StartTrustedDeviceLogin` больше не падает с `NullPointerException`.
5. После создания pairing-запроса проверить, что homeserver/UI может его увидеть и обработать как раньше. 5. После создания pairing-запроса проверить, что homeserver/UI может его увидеть и обработать как раньше.

View File

@ -12,7 +12,7 @@
6. Проверить оба варианта: 6. Проверить оба варианта:
- `APPROVE` возвращает сайту подписанную транзакцию; - `APPROVE` возвращает сайту подписанную транзакцию;
- `REJECT` возвращает отказ. - `REJECT` возвращает отказ.
7. Проверить сценарии для `DeviceKey`, `RootKey`, `Custom`. 7. Проверить сценарии для `ClientKey`, `RootKey`, `Custom`.
- Ожидаемый результат: - Ожидаемый результат:
- сайт может подключить кошелёк через provider расширения; - сайт может подключить кошелёк через provider расширения;

View File

@ -0,0 +1,26 @@
## Кратко
Исправлена ESP32-ветка обновления `user_pda` для добавления `homeserver`-сессии после миграции формата PDA на `RecoveryKeyBlock`.
## Что сделано
- В `shine_homeserver_main.ino` синхронизирован `create/update` payload с новым форматом `shine_users`.
- В сериализацию и парсинг PDA добавлен `RecoveryKeyBlock`.
- Для ветки `Add Homeserver` добавлены промежуточные checkpoint-записи в NVS, чтобы после crash или reset было видно, на каком шаге оборвалась операция.
- В `ESP32/AGENTS.md` добавлена памятка по чтению `last_error`.
## Что проверять
- Зарегистрировать или использовать уже существующий аккаунт на ESP32.
- Дойти до состояния `homeserver not in PDA`.
- Нажать `Add Homeserver`.
- Если операция не успешна, считать `last_error` по USB serial и убедиться, что видна свежая запись именно по шагам `Homeserver PDA update ...`, а не старый diag.
## Ожидаемый результат
- `Add Homeserver` добавляет `homeserver1` в `sessions` блока `SessionsBlock`.
- Если операция падает, в NVS сохраняется свежая диагностическая запись с текущим этапом, а не устаревший лог регистрации.
## Статус
`pending`

View File

@ -0,0 +1,28 @@
# Общий список каналов без stories
- Краткое описание:
вкладка `Каналы` переведена на единый список без разделения на "мои" и "подписки".
Название канала в списке теперь показывается как `login_владельцаазваниеанала`.
Служебный канал `stories` скрыт из списка каналов, поиска, подписки и связанных UI-сценариев.
- Что проверять:
1. Открыть вкладку `Каналы`.
2. Убедиться, что сразу показывается один общий список.
3. Проверить, что свои и чужие каналы отображаются вместе.
4. Проверить формат названий: `ownerLogin/channelName`.
5. Открыть свой канал и убедиться, что внутри сохраняется UI владельца.
6. Открыть чужой канал и убедиться, что внутри сохраняется UI подписчика.
7. Проверить, что `stories` не отображается:
- в общем списке;
- в поиске каналов;
- в подписке на канал;
- в списках выбора канала для репоста.
- Ожидаемый результат:
- вкладка `Каналы` больше не делится на два режима;
- все видимые каналы идут единым списком;
- `stories` нигде не виден и не предлагается пользователю;
- переход в канал сохраняет корректный UI в зависимости от владельца.
- Статус:
`pending`

View File

@ -12,8 +12,9 @@
- логин пользователя; - логин пользователя;
- неизменяемые параметры создания записи; - неизменяемые параметры создания записи;
- публичный recovery-ключ пользователя;
- корневой публичный ключ пользователя; - корневой публичный ключ пользователя;
- ключ устройства; - клиентский публичный ключ пользователя;
- данные одного или нескольких пользовательских блокчейнов SHiNE; - данные одного или нескольких пользовательских блокчейнов SHiNE;
- серверные данные пользователя, если пользователь выступает сервером; - серверные данные пользователя, если пользователь выступает сервером;
- серверы доступа пользователя; - серверы доступа пользователя;
@ -34,9 +35,9 @@
## 2.1. Кто оплачивает create/update PDA ## 2.1. Кто оплачивает create/update PDA
- Инструкции `create_user_pda` и `update_user_pda` оплачиваются с `device_key`. - Инструкции `create_user_pda` и `update_user_pda` оплачиваются с `client_key`.
- `root_key` используется для подписи unsigned части записи через Ed25519 instruction и не является fee payer. - `root_key` используется для подписи unsigned части записи через Ed25519 instruction и не является fee payer.
- Для server PDA это правило то же самое: пополнять SOL нужно на адрес `device_key`. - Для server PDA это правило то же самое: пополнять SOL нужно на адрес `client_key`.
## 3. Общие правила кодирования ## 3. Общие правила кодирования
@ -85,8 +86,9 @@ UserPdaRecordV1
| block_type | Блок | Назначение | | block_type | Блок | Назначение |
|------------|------|------------| |------------|------|------------|
| `0` | `RecoveryKeyBlock` | Ключ восстановления пользователя. |
| `1` | `RootKeyBlock` | Корневой ключ пользователя. | | `1` | `RootKeyBlock` | Корневой ключ пользователя. |
| `2` | `DeviceKeyBlock` | Ключ устройства пользователя. | | `2` | `ClientKeyBlock` | Клиентский ключ пользователя. |
| `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. | | `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. |
| `30` | `ServerProfileBlock` | Серверные данные пользователя. | | `30` | `ServerProfileBlock` | Серверные данные пользователя. |
| `40` | `AccessServersBlock` | Серверы доступа/relay. | | `40` | `AccessServersBlock` | Серверы доступа/relay. |
@ -97,13 +99,31 @@ UserPdaRecordV1
Правила: Правила:
- неизвестный `block_type` в `format_major = 1` считается ошибкой; - неизвестный `block_type` в `format_major = 1` считается ошибкой;
- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`; - обязательные блоки: `RecoveryKeyBlock`, `RootKeyBlock`, `ClientKeyBlock`, `BlockchainRegistryBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`; - необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`;
- каждый обязательный блок должен встречаться ровно один раз; - каждый обязательный блок должен встречаться ровно один раз;
- порядок блоков в записи фиксируется для простоты проверки: - порядок блоков в записи фиксируется для простоты проверки:
`RootKey`, `DeviceKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`. `RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`.
## 6. RootKeyBlock ## 6. RecoveryKeyBlock
Recovery-ключ нужен для будущих сценариев восстановления и ротации остальных ключей. В текущей версии он только публикуется в записи и не меняется через обычный `update_user_pda`.
```text
RecoveryKeyBlock
- block_type: u8 = 0
- block_version: u8 = 0
- recovery_key: [u8; 32]
```
Правила:
- при создании задается публичный recovery-ключ пользователя;
- при обновлении `recovery_key` должен совпадать с предыдущей записью;
- приватный `recovery.key` в PDA не хранится;
- отдельная ротация recovery-ключа будет отдельным форматом/сценарием в будущем.
## 7. RootKeyBlock
Смена `root_key` пока не проектируется и не реализуется. Блок фиксирует только стадию `0`. Смена `root_key` пока не проектируется и не реализуется. Блок фиксирует только стадию `0`.
@ -120,24 +140,24 @@ RootKeyBlock
- при обновлении `root_key` должен совпадать с предыдущей записью; - при обновлении `root_key` должен совпадать с предыдущей записью;
- ротация root-key будет отдельным форматом/сценарием в будущем. - ротация root-key будет отдельным форматом/сценарием в будущем.
## 7. DeviceKeyBlock ## 8. ClientKeyBlock
Смена `device_key` пока также не проектируется как отдельная ротация. В версии `0` хранится один ключ устройства. Смена `client_key` пока также не проектируется как отдельная ротация. В версии `0` хранится один клиентский ключ пользователя.
```text ```text
DeviceKeyBlock ClientKeyBlock
- block_type: u8 = 2 - block_type: u8 = 2
- block_version: u8 = 0 - block_version: u8 = 0
- device_key: [u8; 32] - client_key: [u8; 32]
``` ```
Правила: Правила:
- при создании задается текущий публичный ключ устройства; - при создании задается текущий клиентский публичный ключ пользователя;
- при обновлении ключ устройства может быть обновлен только если это отдельно разрешено бизнес-логикой инструкции; - при обновлении `client_key` должен совпадать с предыдущей записью;
- история устройств и несколько устройств в этом формате не хранятся. - история устройств и несколько клиентских ключей в этом формате не хранятся.
## 8. BlockchainRegistryBlock ## 9. BlockchainRegistryBlock
Блок хранит данные пользовательских блокчейнов SHiNE. Сейчас используется один блокчейн, но структура сразу сделана как список. Блок хранит данные пользовательских блокчейнов SHiNE. Сейчас используется один блокчейн, но структура сразу сделана как список.
@ -155,7 +175,7 @@ BlockchainRegistryBlock
- в будущем можно увеличить количество записей без изменения смысла `BlockchainRecord`; - в будущем можно увеличить количество записей без изменения смысла `BlockchainRecord`;
- каждый `BlockchainRecord` описывает один пользовательский SHiNE-блокчейн. - каждый `BlockchainRecord` описывает один пользовательский SHiNE-блокчейн.
## 9. BlockchainRecord ## 10. BlockchainRecord
```text ```text
BlockchainRecord BlockchainRecord
@ -191,7 +211,7 @@ BlockchainRecord
Arweave `tx_id` - обычное поле внутри записи конкретного блокчейна. Solana-программа не проверяет, что такой Arweave transaction действительно существует и содержит корректные данные; это ответственность клиента/сервера/пользователя. Arweave `tx_id` - обычное поле внутри записи конкретного блокчейна. Solana-программа не проверяет, что такой Arweave transaction действительно существует и содержит корректные данные; это ответственность клиента/сервера/пользователя.
## 10. Правила обновления BlockchainRecord ## 11. Правила обновления BlockchainRecord
При обновлении записи: При обновлении записи:
@ -229,7 +249,7 @@ last_block_signature = Ed25519(blockchain_public_key, message)
Причина проверки подписи `LastBlockState`: `root_key` управляет Solana-записью пользователя, а `blockchain_public_key` подтверждает состояние конкретного пользовательского блокчейна. Подписывается не голый хэш, а связка логина, имени блокчейна, номера последнего блока, хэша последнего блока и занятого размера. Причина проверки подписи `LastBlockState`: `root_key` управляет Solana-записью пользователя, а `blockchain_public_key` подтверждает состояние конкретного пользовательского блокчейна. Подписывается не голый хэш, а связка логина, имени блокчейна, номера последнего блока, хэша последнего блока и занятого размера.
## 11. ServerProfileBlock ## 12. ServerProfileBlock
Блок присутствует, если пользователь выступает сервером. Блок присутствует, если пользователь выступает сервером.
@ -255,7 +275,7 @@ ServerProfileBlock
- `server_address` - строковый адрес сервера в соответствии с `address_format_type`; - `server_address` - строковый адрес сервера в соответствии с `address_format_type`;
- `sync_servers` - логины SHiNE-пользователей, зарегистрированных как серверы, с которыми этот сервер синхронизирует блокчейн и личные сообщения. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы. - `sync_servers` - логины SHiNE-пользователей, зарегистрированных как серверы, с которыми этот сервер синхронизирует блокчейн и личные сообщения. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы.
## 12. AccessServersBlock ## 13. AccessServersBlock
Блок хранит серверы доступа/relay для пользователя. Блок хранит серверы доступа/relay для пользователя.
@ -274,7 +294,7 @@ AccessServersBlock
- `access_servers` - логины пользователей системы, используемых как серверы доступа/relay. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы; - `access_servers` - логины пользователей системы, используемых как серверы доступа/relay. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы;
- точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE. - точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE.
## 13. SessionsBlock ## 14. SessionsBlock
Блок хранит опубликованные пользовательские сессии. На текущем этапе регистрация пользователя не добавляет туда записи автоматически, поэтому стандартный create/update продолжает работать с пустым списком. Блок хранит опубликованные пользовательские сессии. На текущем этапе регистрация пользователя не добавляет туда записи автоматически, поэтому стандартный create/update продолжает работать с пустым списком.
@ -309,6 +329,7 @@ SessionRecord
| Значение | Смысл | | Значение | Смысл |
|----------|-------| |----------|-------|
| `1` | Обычная пользовательская сессия. | | `1` | Обычная пользовательская сессия. |
| `50` | Кошелёк пользователя. |
| `100` | Homeserver пользователя. | | `100` | Homeserver пользователя. |
Правила: Правила:
@ -320,7 +341,7 @@ SessionRecord
- внутри одного блока должны быть уникальны и `session_name`, и `session_pub_key`; - внутри одного блока должны быть уникальны и `session_name`, и `session_pub_key`;
- на текущем этапе UI и регистрация не обязаны добавлять туда записи автоматически. - на текущем этапе UI и регистрация не обязаны добавлять туда записи автоматически.
## 14. TrustedStateBlock ## 15. TrustedStateBlock
Пока trusted-логика не реализована полностью, поэтому блок хранит только счетчик. Пока trusted-логика не реализована полностью, поэтому блок хранит только счетчик.
@ -333,7 +354,7 @@ TrustedStateBlock
Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат. Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат.
## 15. Подпись user_pda ## 16. Подпись user_pda
Подписывается не вся PDA целиком, а unsigned-часть записи: Подписывается не вся PDA целиком, а unsigned-часть записи:
@ -354,7 +375,7 @@ Solana-программа проверяет подпись через встр
Смену формата подписи сейчас не трогаем. Смену формата подписи сейчас не трогаем.
## 16. Регистрация пользователя ## 17. Регистрация пользователя
При регистрации: При регистрации:
@ -372,12 +393,12 @@ Solana-программа проверяет подпись через встр
- если покупается дополнительный лимит, пользователь платит комиссию за этот лимит; - если покупается дополнительный лимит, пользователь платит комиссию за этот лимит;
- вся unsigned-часть записи подписана `root_key`. - вся unsigned-часть записи подписана `root_key`.
## 17. Обновление пользователя ## 18. Обновление пользователя
При обновлении: При обновлении:
- PDA должна существовать; - PDA должна существовать;
- `login`, `created_at_ms`, `root_key` не меняются; - `login`, `created_at_ms`, `recovery_key`, `root_key`, `client_key` не меняются;
- `record_number = previous_record_number + 1`; - `record_number = previous_record_number + 1`;
- `prev_record_hash` равен хэшу unsigned-части предыдущей записи; - `prev_record_hash` равен хэшу unsigned-части предыдущей записи;
- `updated_at_ms` обновляется; - `updated_at_ms` обновляется;
@ -387,7 +408,7 @@ Solana-программа проверяет подпись через встр
- при увеличении оплаченного лимита пользователь доплачивает комиссию; - при увеличении оплаченного лимита пользователь доплачивает комиссию;
- Arweave `tx_id` может быть пустым или обновленным, но его содержимое Solana не валидирует. - Arweave `tx_id` может быть пустым или обновленным, но его содержимое Solana не валидирует.
## 18. Отличия от старого линейного формата ## 19. Отличия от старого линейного формата
Старый формат после `login` хранил поля линейно: Старый формат после `login` хранил поля линейно:
@ -395,8 +416,8 @@ Solana-программа проверяет подпись через встр
- `root_key`; - `root_key`;
- `blockchain_key_status`; - `blockchain_key_status`;
- `blockchain_key`; - `blockchain_key`;
- `device_key_status`; - `client_key_status`;
- `device_key`; - `client_key`;
- `chain_number`; - `chain_number`;
- `balance`; - `balance`;
- серверные поля; - серверные поля;
@ -407,17 +428,54 @@ Solana-программа проверяет подпись через встр
Новый целевой формат сохраняет первые 9 фиксированных полей как заголовок, но дальше переходит на типизированные блоки: Новый целевой формат сохраняет первые 9 фиксированных полей как заголовок, но дальше переходит на типизированные блоки:
- recovery-ключ становится отдельным обязательным блоком;
- ключи становятся отдельными блоками; - ключи становятся отдельными блоками;
- данные блокчейна становятся расширенным блоком со своим публичным ключом, лимитом, занятым размером, вершиной цепочки и Arweave `tx_id`; - данные блокчейна становятся расширенным блоком со своим публичным ключом, лимитом, занятым размером, вершиной цепочки и Arweave `tx_id`;
- серверные данные и access-серверы отделяются от данных блокчейна; - серверные данные и access-серверы отделяются от данных блокчейна;
- расширение формата делается добавлением новых версий блоков или новых `block_type`, а не вставкой полей в середину линейной записи. - расширение формата делается добавлением новых версий блоков или новых `block_type`, а не вставкой полей в середину линейной записи.
## 18. Что пока не входит в формат ## 20. Деривация ключей из master secret
Сама Solana-программа не вычисляет ключи из секрета и не хранит приватные ключи. Но текущая согласованная клиентская схема деривации для публичной версии формата фиксируется здесь как reference для UI/ESP32/внешних клиентов.
Базовая формула:
```text
seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || suffix_utf8)
```
Где:
- `master_secret32` — 32-байтовый master secret пользователя;
- `suffix_utf8` — строка назначения ключа.
Согласованные suffix:
```text
"recovery.key"
"root.key"
"blockchain.key"
"client.key"
```
Соответствие:
```text
recovery.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "recovery.key")
root.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "root.key")
blockchain.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "blockchain.key")
client.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "client.key")
```
Далее каждая строка `seed` интерпретируется off-chain как `seed32` для отдельной пары Ed25519.
## 21. Что пока не входит в формат
Пока не проектируем: Пока не проектируем:
- ротацию `recovery_key`;
- ротацию `root_key`; - ротацию `root_key`;
- сложную ротацию `device_key`; - сложную ротацию `client_key`;
- ротацию `blockchain_public_key`; - ротацию `blockchain_public_key`;
- проверку содержимого Arweave transaction; - проверку содержимого Arweave transaction;
- хранение полной истории пользовательского блокчейна внутри Solana; - хранение полной истории пользовательского блокчейна внутри Solana;

View File

@ -47,7 +47,7 @@ DAO в текущем виде не является отдельной Anchor-
| Программа | Program ID | | Программа | Program ID |
| --- | --- | | --- | --- |
| `shine_login_guard` | `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` | | `shine_login_guard` | `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` |
| `shine_users` | `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` | | `shine_users` | `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ` |
| `shine_payments` | `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW` | | `shine_payments` | `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW` |
Если эти адреса меняются, нужно синхронно обновить: Если эти адреса меняются, нужно синхронно обновить:

View File

@ -19,7 +19,7 @@
5. `DAO_TREASURY_WALLET` / `dao_wallet` — казна DAO. 5. `DAO_TREASURY_WALLET` / `dao_wallet` — казна DAO.
6. `manager_wallet` — кошелек менеджера, которому DAO выдает лимиты на создание тикетов. 6. `manager_wallet` — кошелек менеджера, которому DAO выдает лимиты на создание тикетов.
7. `user root_key` — корневой ключ пользователя для подписи пользовательской записи. 7. `user root_key` — корневой ключ пользователя для подписи пользовательской записи.
8. `user device_key` — ключ устройства пользователя. 8. `user client_key` — ключ устройства пользователя.
9. `server_key` — ключ сервера пользователя, если пользователь является сервером. 9. `server_key` — ключ сервера пользователя, если пользователь является сервером.
Текущие адреса из `programs/common/src/deploy_config.rs`: Текущие адреса из `programs/common/src/deploy_config.rs`:
@ -27,7 +27,7 @@
| Роль | Адрес | | Роль | Адрес |
| --- | --- | | --- | --- |
| `SHINE_LOGIN_GUARD_PROGRAM_ID` | `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` | | `SHINE_LOGIN_GUARD_PROGRAM_ID` | `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` |
| `SHINE_USERS_PROGRAM_ID` | `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` | | `SHINE_USERS_PROGRAM_ID` | `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ` |
| `SHINE_PAYMENTS_PROGRAM_ID` | `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW` | | `SHINE_PAYMENTS_PROGRAM_ID` | `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW` |
| `DAO_AUTHORITY` | `FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P` | | `DAO_AUTHORITY` | `FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P` |
| `DAO_TREASURY_WALLET` | `FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P` | | `DAO_TREASURY_WALLET` | `FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P` |

View File

@ -62,7 +62,7 @@
`UserMutableFields`: `UserMutableFields`:
- `device_key: Pubkey` - `client_key: Pubkey`
- `blockchain_public_key: Pubkey` - `blockchain_public_key: Pubkey`
- `blockchain_name: String` - `blockchain_name: String`
- `used_bytes: u64` - `used_bytes: u64`

View File

@ -32,7 +32,7 @@
<rect class="box actor" x="52" y="150" width="210" height="78"/> <rect class="box actor" x="52" y="150" width="210" height="78"/>
<text class="txt" x="72" y="181">Пользователь</text> <text class="txt" x="72" y="181">Пользователь</text>
<text class="small" x="72" y="206">signer, root_key, device_key</text> <text class="small" x="72" y="206">signer, root_key, client_key</text>
<rect class="box actor" x="52" y="310" width="210" height="78"/> <rect class="box actor" x="52" y="310" width="210" height="78"/>
<text class="txt" x="72" y="341">Покупатель тикета</text> <text class="txt" x="72" y="341">Покупатель тикета</text>

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -43,8 +43,8 @@ bump, подмена аккаунта оракула) не найдено. Вс
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| `init_users_economy_config` | ✓ | `owner == system` + `data_is_empty` (анти-reinit) | деривация + сверка | ✓ | — | значения из `settings`, не из ввода | | `init_users_economy_config` | ✓ | `owner == system` + `data_is_empty` (анти-reinit) | деривация + сверка | ✓ | — | значения из `settings`, не из ввода |
| `update_users_economy_config` | ✓ + `signer == DAO_AUTHORITY` | `owner == program_id` | деривация + сверка | — | — | `lamports_per_limit_step > 0` | | `update_users_economy_config` | ✓ + `signer == DAO_AUTHORITY` | `owner == program_id` | деривация + сверка | — | — | `lamports_per_limit_step > 0` |
| `create_user_pda` | ✓ + `signer == device_key` | user_pda `owner == system` + empty; econ_config `owner == program_id` | user_pda, econ_config, inflow_vault, login_guard — все сверены | ✓ | ed25519 (record sig idx 2, last_block idx 1) | `inflow_vault` сверен с PDA `shine_payments`; login_guard сверен дважды | | `create_user_pda` | ✓ + `signer == client_key` | user_pda `owner == system` + empty; econ_config `owner == program_id` | user_pda, econ_config, inflow_vault, login_guard — все сверены | ✓ | ed25519 (record sig idx 2, last_block idx 1) | `inflow_vault` сверен с PDA `shine_payments`; login_guard сверен дважды |
| `update_user_pda` | ✓ + `signer == device_key` | user_pda `owner == program_id`; econ_config `owner == program_id` | деривация + сверка | ✓ | ed25519 + `version == old+1` + `prev_hash == hash(old)` | immutable-поля сверены с прежней записью | | `update_user_pda` | ✓ + `signer == client_key` | user_pda `owner == program_id`; econ_config `owner == program_id` | деривация + сверка | ✓ | ed25519 + `version == old+1` + `prev_hash == hash(old)` | immutable-поля сверены с прежней записью |
### shine_payments ### shine_payments
@ -111,7 +111,7 @@ commit-reveal; для текущей модели — приемлемый ри
- **Подмена PDA** невозможна нигде: всюду пара «деривация `find_program_address` + сверка полного адреса». Пользовательский bump не принимается, `create_program_address` с внешним bump не используется — bump-seed атаки исключены. - **Подмена PDA** невозможна нигде: всюду пара «деривация `find_program_address` + сверка полного адреса». Пользовательский bump не принимается, `create_program_address` с внешним bump не используется — bump-seed атаки исключены.
- **Проверка владельца** при каждом чтении PDA: `read_state` и `validate_singleton_state_pda` (`shine_payments`) требуют `owner == id()`; `validate_users_economy_config_pda` и проверка `user_pda.owner == program_id` (`shine_users`) — перед десериализацией данных. - **Проверка владельца** при каждом чтении PDA: `read_state` и `validate_singleton_state_pda` (`shine_payments`) требуют `owner == id()`; `validate_users_economy_config_pda` и проверка `user_pda.owner == program_id` (`shine_users`) — перед десериализацией данных.
- **Создаваемые PDA**: проверка `is_uninitialized` / `owner == system && data_is_empty` исключает повторную инициализацию и перезапись чужого аккаунта. - **Создаваемые PDA**: проверка `is_uninitialized` / `owner == system && data_is_empty` исключает повторную инициализацию и перезапись чужого аккаунта.
- **signer / authority**: все handler начинают с обязательного `is_signer`; привилегированные операции дополнительно сверяют ключ с авторитетом (`config.dao_wallet`, `DAO_AUTHORITY`, `allowance.manager_wallet`, `ticket.recipient_wallet`, `device_key`). - **signer / authority**: все handler начинают с обязательного `is_signer`; привилегированные операции дополнительно сверяют ключ с авторитетом (`config.dao_wallet`, `DAO_AUTHORITY`, `allowance.manager_wallet`, `ticket.recipient_wallet`, `client_key`).
- **system-program** сверяется с `system_program::ID` там, где идёт создание аккаунта/перевод; **sysvar инструкций** сверяется с `sysvar::instructions::id()` перед ed25519-интроспекцией. - **system-program** сверяется с `system_program::ID` там, где идёт создание аккаунта/перевод; **sysvar инструкций** сверяется с `sysvar::instructions::id()` перед ed25519-интроспекцией.
- **Аккаунт оракула**: пин адреса `PYTH_SOL_USD_ACCOUNT` + `owner == pyth_receiver` + `feed_id` + возраст (120 с) + доверительный интервал (10%). - **Аккаунт оракула**: пин адреса `PYTH_SOL_USD_ACCOUNT` + `owner == pyth_receiver` + `feed_id` + возраст (120 с) + доверительный интервал (10%).
- **Ed25519 в `shine_users`**: относительные индексы 1/2, `num_signatures == 1`, все три `ix_index == u16::MAX` (offset-данные внутри самой ed25519-инструкции), сверка `program_id == ed25519_program` и pubkey/signature/message по хэшу — указать на чужую инструкцию нельзя. - **Ed25519 в `shine_users`**: относительные индексы 1/2, `num_signatures == 1`, все три `ix_index == u16::MAX` (offset-данные внутри самой ed25519-инструкции), сверка `program_id == ed25519_program` и pubkey/signature/message по хэшу — указать на чужую инструкцию нельзя.

View File

@ -12,7 +12,7 @@
## Актуальные адреса (devnet) ## Актуальные адреса (devnet)
- `shine_users` (регистрация пользователей): - `shine_users` (регистрация пользователей):
`FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ`
- `shine_login_guard`: - `shine_login_guard`:
`3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo`
- `shine_payments`: - `shine_payments`:
@ -22,7 +22,7 @@
- Сеть: `https://api.devnet.solana.com` - Сеть: `https://api.devnet.solana.com`
- `shine_users`: - `shine_users`:
- `Program ID`: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` - `Program ID`: `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ`
- TX deploy: `5VzfpSirFCRqPUZfvAt3eADY9KnowW79PKZ1pCQAa2DJGiztj4dUYYXrSQNmWEhPVu6mPSDfcuHzFyEVmoKLa9DM` - TX deploy: `5VzfpSirFCRqPUZfvAt3eADY9KnowW79PKZ1pCQAa2DJGiztj4dUYYXrSQNmWEhPVu6mPSDfcuHzFyEVmoKLa9DM`
- `shine_login_guard`: - `shine_login_guard`:
- `Program ID`: `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` - `Program ID`: `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo`
@ -64,11 +64,38 @@ anchor deploy -p shine_users
- Переход на страницу: - Переход на страницу:
- `shine-UI/js/pages/developer-settings-view.js` - `shine-UI/js/pages/developer-settings-view.js`
### Browser plugin wallet
- Резолвер PDA и проверка адресов:
- `SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js`
- Проверка текущего wallet по PDA:
- `SHiNE-browser-plugin-wallet/background.js`
- Отображение состояния в popup:
- `SHiNE-browser-plugin-wallet/popup.js`
### Сервер ### Сервер
- Серверные константы Solana: - Серверные константы Solana:
- `shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java` - `shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java`
### Solana / Anchor
- `shine-solana/shine/Anchor.toml`
- `shine-solana/shine/programs/shine_users/src/lib.rs` (`declare_id!`)
- `shine-solana/shine/programs/shine_login_guard/src/lib.rs` (`declare_id!`)
- `shine-solana/shine/programs/shine_payments/src/lib.rs` (`declare_id!`)
### Где ещё нужно синхронизировать адреса после нового deploy
- UI-константы и все потребители в `shine-UI/js/*`
- browser-plugin-wallet
- серверный `SolanaProgramsConfig.java`
- Anchor-конфиг и `declare_id!` в Solana-модуле
- документы:
- `Dev_Docs/Solana/user_pda/README.md`
- `shine-solana/shine/doc/programs/shine_users.md`
- `shine-solana/shine/doc/devnet_keys_and_deploy.md`
## Как запустить инициализацию economy PDA ## Как запустить инициализацию economy PDA
1. Открыть UI. 1. Открыть UI.
@ -80,19 +107,19 @@ anchor deploy -p shine_users
Страница сама вычисляет PDA `users_economy_config` по seed: Страница сама вычисляет PDA `users_economy_config` по seed:
- seed: `shine_users_economy_config` - seed: `shine_users_economy_config`
- program: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` - program: `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ`
## Кто оплачивает create/update user_pda ## Кто оплачивает create/update user_pda
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `deviceKey`. - И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `clientKey`.
- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя/сервера. - В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя/сервера.
- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию. - `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию.
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `deviceKey`. - Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `clientKey`.
## Важно ## Важно
- `init_users_economy_config` выполняется один раз на программу. - `init_users_economy_config` выполняется один раз на программу.
Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение). Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение).
- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: в UI/server UI транзакцию оплачивает именно `deviceKey`, а содержимое записи подписывает `rootKey`. - Серверные приватные ключи для Solana не используются как отдельный backend-wallet: в UI/server UI транзакцию оплачивает именно `clientKey`, а содержимое записи подписывает `rootKey`.
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина. - `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
Несовпадение адреса приведёт к ошибке регистрации. Несовпадение адреса приведёт к ошибке регистрации.

View File

@ -4,7 +4,7 @@
## 1. Текущие режимы ## 1. Текущие режимы
### 1. Создание новой сессии через `deviceKey` ### 1. Создание новой сессии через `clientKey`
Поток: Поток:
@ -12,8 +12,8 @@
Смысл: Смысл:
- новое устройство уже владеет приватным `deviceKey`; - новое устройство уже владеет приватным `clientKey`;
- сервер проверяет подпись `deviceKey`; - сервер проверяет подпись `clientKey`;
- создаётся обычная активная сессия пользователя; - создаётся обычная активная сессия пользователя;
- этот поток остаётся без изменений. - этот поток остаётся без изменений.
@ -67,7 +67,7 @@
## 4. Чего сервер в этой версии не делает ## 4. Чего сервер в этой версии не делает
- не передаёт приватный `deviceKey`; - не передаёт приватный `clientKey`;
- не расшифровывает `encryptedPayload`; - не расшифровывает `encryptedPayload`;
- не проверяет криптографию содержимого payload; - не проверяет криптографию содержимого payload;
- не делает клиентский UI; - не делает клиентский UI;

View File

@ -3,11 +3,11 @@
Сокращение: **SAWD-v1**. Сокращение: **SAWD-v1**.
## Назначение ## Назначение
Из 32-байтного `deviceKey32` пользователя получить один и тот же нативный Arweave RSA-4096 JWK wallet и один и тот же Arweave address. Из 32-байтного `clientKey32` пользователя получить один и тот же нативный Arweave RSA-4096 JWK wallet и один и тот же Arweave address.
## Вход ## Вход
- `deviceKey32`: ровно 32 байта. - `clientKey32`: ровно 32 байта.
- Если исходный `device.key` хранится как Ed25519 PKCS8 base64, нужно извлечь последние 32 байта из PKCS8. - Если исходный `client.key` хранится как Ed25519 PKCS8 base64, нужно извлечь последние 32 байта из PKCS8.
- Если используется Solana keypair JSON на 64 байта, используются только `bytes[0..31]`. - Если используется Solana keypair JSON на 64 байта, используются только `bytes[0..31]`.
## Выход ## Выход
@ -46,8 +46,8 @@
- `SMALL_PRIME_LIMIT = 10000` - `SMALL_PRIME_LIMIT = 10000`
## Алгоритм ## Алгоритм
1. Проверить `deviceKey32.length == 32`. 1. Проверить `clientKey32.length == 32`.
2. `masterSeed32 = HMAC-SHA256(key = UTF8(MASTER_LABEL), message = deviceKey32)`. 2. `masterSeed32 = HMAC-SHA256(key = UTF8(MASTER_LABEL), message = clientKey32)`.
3. Реализовать `deriveBytes(label, length)`: 3. Реализовать `deriveBytes(label, length)`:
- `output = empty` - `output = empty`
- `counter = 0` - `counter = 0`

View File

@ -16,7 +16,7 @@
На устройстве в UI пользователь выбирает текущий активный кошелёк: На устройстве в UI пользователь выбирает текущий активный кошелёк:
- `dev.key` - `client.key`
- `root.key` - `root.key`
- `custom` - `custom`
@ -115,7 +115,7 @@ ESP32 возвращает:
- `requestId` — должен совпадать с `requestId` исходного запроса. - `requestId` — должен совпадать с `requestId` исходного запроса.
- `ok` — признак успешного результата. - `ok` — признак успешного результата.
- `wallet.type` — тип активного кошелька: - `wallet.type` — тип активного кошелька:
- `dev.key` - `client.key`
- `root.key` - `root.key`
- `custom` - `custom`
- `wallet.publicKeyBase58` — публичный ключ активного кошелька в `Base58`. - `wallet.publicKeyBase58` — публичный ключ активного кошелька в `Base58`.
@ -156,11 +156,11 @@ ESP32 возвращает:
Расширение уже знает публичные ключи пользователя из Solana PDA. Поэтому оно может дополнительно проверить ответ ESP32: Расширение уже знает публичные ключи пользователя из Solana PDA. Поэтому оно может дополнительно проверить ответ ESP32:
- если `wallet.type = dev.key`, то `publicKeyBase58` должен совпасть с `deviceKey`, прочитанным из PDA; - если `wallet.type = client.key`, то `publicKeyBase58` должен совпасть с `clientKey`, прочитанным из PDA;
- если `wallet.type = root.key`, то `publicKeyBase58` должен совпасть с `rootKey`, прочитанным из PDA; - если `wallet.type = root.key`, то `publicKeyBase58` должен совпасть с `rootKey`, прочитанным из PDA;
- если `wallet.type = custom`, такой проверки по PDA в первой версии нет. - если `wallet.type = custom`, такой проверки по PDA в первой версии нет.
При несовпадении для `dev.key` или `root.key` расширение должно показать пользователю предупреждение, что возвращённый ключ не совпал с ожидаемым ключом из PDA. При несовпадении для `client.key` или `root.key` расширение должно показать пользователю предупреждение, что возвращённый ключ не совпал с ожидаемым ключом из PDA.
## 8. Ожидаемое поведение UI расширения ## 8. Ожидаемое поведение UI расширения

View File

@ -9,3 +9,20 @@
## Синхронизация со спецификацией ## Синхронизация со спецификацией
- При изменении экранов, кнопок, переходов, статусов или текстов обязательно обновлять соответствующую спецификацию в `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/`. - При изменении экранов, кнопок, переходов, статусов или текстов обязательно обновлять соответствующую спецификацию в `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/`.
## Сборка ESP32
- Основной способ проверки и прошивки скетчей для `ESP32-S3-Touch-AMOLED-2.16` - `main-device/burn.sh`.
- Не собирать эти скетчи напрямую через `arduino-cli compile` без `burn.sh`, потому что скрипт добавляет нужные локальные библиотеки и конфиги из `official-demo/examples/Arduino-v3.3.5/libraries`.
- Если сборка падает по `lv_conf.h` или `TouchDrvCSTXXX.hpp`, сначала проверять именно `burn.sh` и его `--library` пути, а не считать, что файл пропал из репозитория.
## Диагностика ESP32
- Последнюю сохранённую ошибку или диагностическую запись читать с устройства через USB serial monitor на `115200`.
- Базовая команда:
`arduino-cli monitor -p /dev/ttyACM0 --config baudrate=115200`
- После подключения отправлять одну из команд:
`last_error`, `last_diag` или `reg_diag`
- Для очистки сохранённой диагностики использовать:
`clear_error` или `clear_diag`
- При падениях в ветках регистрации и обновления PDA сначала читать именно `last_error`: запись хранится в NVS и может пережить перезагрузку устройства.

View File

@ -11,7 +11,7 @@
* legacy(empty password): * legacy(empty password):
* secret = SHA256(base64(SHA256(password)) + "master.secret") * secret = SHA256(base64(SHA256(password)) + "master.secret")
* keyPair_i = Ed25519(SHA256(base64(secret) + "|" + suffix_i)) * keyPair_i = Ed25519(SHA256(base64(secret) + "|" + suffix_i))
* suffixes = ["root.key", "bch.key", "dev.key"] * suffixes = ["root.key", "blockchain.key", "client.key"]
* *
* Плата: Waveshare ESP32-S3-Touch-AMOLED-2.16 * Плата: Waveshare ESP32-S3-Touch-AMOLED-2.16
* SD : SDMMC 1-bit CLK=GPIO2, CMD=GPIO1, D0=GPIO3 * SD : SDMMC 1-bit CLK=GPIO2, CMD=GPIO1, D0=GPIO3
@ -116,8 +116,8 @@ static bool gKbNums = false;
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
struct KeyPair { uint8_t pub[32]; uint8_t priv[32]; }; struct KeyPair { uint8_t pub[32]; uint8_t priv[32]; };
static KeyPair gKeys[3]; static KeyPair gKeys[3];
static const char * KEY_SUFFIXES[3] = {"root.key", "bch.key", "dev.key"}; static const char * KEY_SUFFIXES[3] = {"root.key", "blockchain.key", "client.key"};
static const char * KEY_LABELS[3] = {"root.key", "bch.key", "dev.key"}; static const char * KEY_LABELS[3] = {"root.key", "blockchain.key", "client.key"};
static uint32_t gElapsedSec = 0; static uint32_t gElapsedSec = 0;
// Base58 представления (43-44 символа для 32 байт + \0) // Base58 представления (43-44 символа для 32 байт + \0)

View File

@ -1,7 +1,6 @@
#include "shine_secret_generation.h" #include "shine_secret_generation.h"
#include <SD_MMC.h> #include <SD_MMC.h>
#include <Preferences.h>
#include <mbedtls/sha256.h> #include <mbedtls/sha256.h>
#include <mbedtls/base64.h> #include <mbedtls/base64.h>
#include <string.h> #include <string.h>
@ -80,6 +79,19 @@ static void setMessage(const char *message) {
snprintf(gMessage, sizeof(gMessage), "%s", message ? message : ""); snprintf(gMessage, sizeof(gMessage), "%s", message ? message : "");
} }
static void sha256calc(const uint8_t *in, size_t len, uint8_t *out32);
static void finishSecretFromBytes(const uint8_t secret32[32], const char *message) {
memcpy(gSecret, secret32, 32);
shineSecretBase58Encode(gSecret, 32, gSecretB58, sizeof(gSecretB58));
gDone = true;
gRunning = false;
gError = false;
gInitDone = false;
gDoneBlocks = TOTAL_FILLS;
setMessage(message ? message : "Secret generated");
}
static void b2_compress(B2State *S, const uint8_t *blk) { static void b2_compress(B2State *S, const uint8_t *blk) {
uint64_t m[16], v[16]; uint64_t m[16], v[16];
for (int i = 0; i < 16; i++) m[i] = ((const uint64_t *)blk)[i]; for (int i = 0; i < 16; i++) m[i] = ((const uint64_t *)blk)[i];
@ -449,7 +461,6 @@ bool shineSecretInitSd(String &error) {
bool shineSecretStart(const char *normalizedLogin, const char *password, String &error) { bool shineSecretStart(const char *normalizedLogin, const char *password, String &error) {
error = ""; error = "";
if (!shineSecretInitSd(error)) return false;
if (!normalizedLogin || !*normalizedLogin) { if (!normalizedLogin || !*normalizedLogin) {
error = "login not set"; error = "login not set";
return false; return false;
@ -463,6 +474,8 @@ bool shineSecretStart(const char *normalizedLogin, const char *password, String
return false; return false;
} }
if (!shineSecretInitSd(error)) return false;
if (gSdFile) gSdFile.close(); if (gSdFile) gSdFile.close();
SD_MMC.remove(SD_MEM_FILE); SD_MMC.remove(SD_MEM_FILE);
gSdFile = SD_MMC.open(SD_MEM_FILE, "w+"); gSdFile = SD_MMC.open(SD_MEM_FILE, "w+");

View File

@ -224,12 +224,14 @@ static int16_t gTouchLastY = 0;
struct DerivedKeyState { struct DerivedKeyState {
bool ready; bool ready;
uint8_t masterSecret[32]; uint8_t masterSecret[32];
uint8_t recoveryPub[32];
uint8_t recoverySk[64];
uint8_t rootPub[32]; uint8_t rootPub[32];
uint8_t rootSk[64]; uint8_t rootSk[64];
uint8_t blockchainPub[32]; uint8_t blockchainPub[32];
uint8_t blockchainSk[64]; uint8_t blockchainSk[64];
uint8_t devicePub[32]; uint8_t clientPub[32];
uint8_t deviceSk[64]; uint8_t clientSk[64];
}; };
static DerivedKeyState gDerivedKeys = {}; static DerivedKeyState gDerivedKeys = {};
@ -237,7 +239,7 @@ static DerivedKeyState gDerivedKeys = {};
static const char *kSystemProgramId = "11111111111111111111111111111111"; static const char *kSystemProgramId = "11111111111111111111111111111111";
static const char *kEd25519ProgramId = "Ed25519SigVerify111111111111111111111111111"; static const char *kEd25519ProgramId = "Ed25519SigVerify111111111111111111111111111";
static const char *kSysvarInstructionsId = "Sysvar1nstructions1111111111111111111111111"; static const char *kSysvarInstructionsId = "Sysvar1nstructions1111111111111111111111111";
static const char *kShineUsersProgramId = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm"; static const char *kShineUsersProgramId = "3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ";
static const char *kShineLoginGuardProgramId = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo"; static const char *kShineLoginGuardProgramId = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo";
static const char *kShinePaymentsProgramId = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW"; static const char *kShinePaymentsProgramId = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW";
static const char *kUsersSeedPrefix = "user_login="; static const char *kUsersSeedPrefix = "user_login=";
@ -246,7 +248,7 @@ static const char *kPaymentsInflowSeed = "shine_payments_inflow_vault";
static const char *kProgramDerivedAddressMarker = "ProgramDerivedAddress"; static const char *kProgramDerivedAddressMarker = "ProgramDerivedAddress";
static const char *kLastBlockPrefix = "SHiNE_LAST_BLOCK"; static const char *kLastBlockPrefix = "SHiNE_LAST_BLOCK";
static const uint8_t kBlockTypeRootKey = 1; static const uint8_t kBlockTypeRootKey = 1;
static const uint8_t kBlockTypeDeviceKey = 2; static const uint8_t kBlockTypeClientKey = 2;
static const uint8_t kBlockTypeBlockchainRegistry = 3; static const uint8_t kBlockTypeBlockchainRegistry = 3;
static const uint8_t kBlockTypeServerProfile = 30; static const uint8_t kBlockTypeServerProfile = 30;
static const uint8_t kBlockTypeAccessServers = 40; static const uint8_t kBlockTypeAccessServers = 40;
@ -782,19 +784,20 @@ static void pushFixed(std::vector<uint8_t> &out, const uint8_t *data, size_t len
static bool deriveKeysFromMasterSecret(const uint8_t masterSecret[32]) { static bool deriveKeysFromMasterSecret(const uint8_t masterSecret[32]) {
memset(&gDerivedKeys, 0, sizeof(gDerivedKeys)); memset(&gDerivedKeys, 0, sizeof(gDerivedKeys));
memcpy(gDerivedKeys.masterSecret, masterSecret, 32); memcpy(gDerivedKeys.masterSecret, masterSecret, 32);
String secretB64 = base64Encode(masterSecret, 32); const char *prefix = "SHiNE-key";
if (secretB64.length() == 0) { const char *suffixes[4] = {"recovery.key", "root.key", "blockchain.key", "client.key"};
return false; uint8_t *pubs[4] = {gDerivedKeys.recoveryPub, gDerivedKeys.rootPub, gDerivedKeys.blockchainPub, gDerivedKeys.clientPub};
} uint8_t *sks[4] = {gDerivedKeys.recoverySk, gDerivedKeys.rootSk, gDerivedKeys.blockchainSk, gDerivedKeys.clientSk};
const char *suffixes[3] = {"root.key", "bch.key", "dev.key"}; for (int i = 0; i < 4; i++) {
uint8_t *pubs[3] = {gDerivedKeys.rootPub, gDerivedKeys.blockchainPub, gDerivedKeys.devicePub}; std::vector<uint8_t> material;
uint8_t *sks[3] = {gDerivedKeys.rootSk, gDerivedKeys.blockchainSk, gDerivedKeys.deviceSk}; material.reserve(strlen(prefix) + 1 + 32 + 1 + strlen(suffixes[i]));
for (int i = 0; i < 3; i++) { material.insert(material.end(), prefix, prefix + strlen(prefix));
String material = secretB64 + "|" + suffixes[i]; material.push_back(0);
material.insert(material.end(), masterSecret, masterSecret + 32);
material.push_back(0);
material.insert(material.end(), suffixes[i], suffixes[i] + strlen(suffixes[i]));
uint8_t seed[32]; uint8_t seed[32];
if (!sha256String(material, seed)) { sha256Raw(material.data(), material.size(), seed);
return false;
}
if (crypto_sign_seed_keypair(pubs[i], sks[i], seed) != 0) { if (crypto_sign_seed_keypair(pubs[i], sks[i], seed) != 0) {
return false; return false;
} }
@ -822,7 +825,7 @@ static bool restoreDerivedKeysFromSecret() {
return false; return false;
} }
gData.secretReady = true; gData.secretReady = true;
gData.walletAddress = bytesToBase58(gDerivedKeys.devicePub, 32); gData.walletAddress = bytesToBase58(gDerivedKeys.clientPub, 32);
return true; return true;
} }
@ -835,7 +838,7 @@ static bool deriveFreshSecretAndWallet() {
return false; return false;
} }
gData.secret = bytesToBase58(secret, sizeof(secret)); gData.secret = bytesToBase58(secret, sizeof(secret));
gData.walletAddress = bytesToBase58(gDerivedKeys.devicePub, 32); gData.walletAddress = bytesToBase58(gDerivedKeys.clientPub, 32);
gData.secretReady = true; gData.secretReady = true;
return true; return true;
} }
@ -889,7 +892,7 @@ static std::vector<uint8_t> buildUnsignedCreateRecord(
const String &blockchainName, const String &blockchainName,
const String &serverAddress, const String &serverAddress,
const uint8_t rootPub[32], const uint8_t rootPub[32],
const uint8_t devicePub[32], const uint8_t clientPub[32],
const uint8_t blockchainPub[32], const uint8_t blockchainPub[32],
const uint8_t lastBlockSignature[64], const uint8_t lastBlockSignature[64],
uint64_t createdAtMs) { uint64_t createdAtMs) {
@ -911,9 +914,9 @@ static std::vector<uint8_t> buildUnsignedCreateRecord(
out.push_back(0); out.push_back(0);
pushFixed(out, rootPub, 32); pushFixed(out, rootPub, 32);
out.push_back(kBlockTypeDeviceKey); out.push_back(kBlockTypeClientKey);
out.push_back(0); out.push_back(0);
pushFixed(out, devicePub, 32); pushFixed(out, clientPub, 32);
out.push_back(kBlockTypeBlockchainRegistry); out.push_back(kBlockTypeBlockchainRegistry);
out.push_back(0); out.push_back(0);
@ -960,7 +963,7 @@ static std::vector<uint8_t> buildCreateInstructionData(
const String &blockchainName, const String &blockchainName,
const String &serverAddress, const String &serverAddress,
const uint8_t rootPub[32], const uint8_t rootPub[32],
const uint8_t devicePub[32], const uint8_t clientPub[32],
const uint8_t blockchainPub[32], const uint8_t blockchainPub[32],
const uint8_t lastBlockSignature[64], const uint8_t lastBlockSignature[64],
const uint8_t rootSignature[64], const uint8_t rootSignature[64],
@ -972,7 +975,7 @@ static std::vector<uint8_t> buildCreateInstructionData(
pushFixed(out, rootPub, 32); pushFixed(out, rootPub, 32);
pushU64LE(out, createdAtMs); pushU64LE(out, createdAtMs);
pushU64LE(out, 0); pushU64LE(out, 0);
pushFixed(out, devicePub, 32); pushFixed(out, clientPub, 32);
pushFixed(out, blockchainPub, 32); pushFixed(out, blockchainPub, 32);
pushStrU8(out, blockchainName); pushStrU8(out, blockchainName);
pushU64LE(out, 0); pushU64LE(out, 0);
@ -1079,7 +1082,7 @@ static bool pdaAlreadyExists(const String &login, String &pdaAddress, String &me
static std::vector<uint8_t> buildLegacyMessage( static std::vector<uint8_t> buildLegacyMessage(
const uint8_t recentBlockhash[32], const uint8_t recentBlockhash[32],
const uint8_t devicePub[32], const uint8_t clientPub[32],
const uint8_t userPda[32], const uint8_t userPda[32],
const uint8_t inflowVault[32], const uint8_t inflowVault[32],
const uint8_t economyConfig[32], const uint8_t economyConfig[32],
@ -1098,7 +1101,7 @@ static std::vector<uint8_t> buildLegacyMessage(
base58ToFixed32(kShineLoginGuardProgramId, loginGuardProgram); base58ToFixed32(kShineLoginGuardProgramId, loginGuardProgram);
std::vector<std::vector<uint8_t>> accountKeys; std::vector<std::vector<uint8_t>> accountKeys;
accountKeys.emplace_back(devicePub, devicePub + 32); accountKeys.emplace_back(clientPub, clientPub + 32);
accountKeys.emplace_back(userPda, userPda + 32); accountKeys.emplace_back(userPda, userPda + 32);
accountKeys.emplace_back(inflowVault, inflowVault + 32); accountKeys.emplace_back(inflowVault, inflowVault + 32);
accountKeys.emplace_back(systemProgram, systemProgram + 32); accountKeys.emplace_back(systemProgram, systemProgram + 32);
@ -1244,7 +1247,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
uint64_t createdAtMs = (uint64_t)millis() + 1704067200000ULL; uint64_t createdAtMs = (uint64_t)millis() + 1704067200000ULL;
std::vector<uint8_t> unsignedRecord = buildUnsignedCreateRecord( std::vector<uint8_t> unsignedRecord = buildUnsignedCreateRecord(
gData.login, blockchainName, gData.wsUrl, gData.login, blockchainName, gData.wsUrl,
gDerivedKeys.rootPub, gDerivedKeys.devicePub, gDerivedKeys.blockchainPub, gDerivedKeys.rootPub, gDerivedKeys.clientPub, gDerivedKeys.blockchainPub,
lastBlockSignature, createdAtMs); lastBlockSignature, createdAtMs);
uint8_t unsignedHash[32]; uint8_t unsignedHash[32];
uint8_t rootSignature[64]; uint8_t rootSignature[64];
@ -1256,7 +1259,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
std::vector<uint8_t> createData = buildCreateInstructionData( std::vector<uint8_t> createData = buildCreateInstructionData(
gData.login, blockchainName, gData.wsUrl, gData.login, blockchainName, gData.wsUrl,
gDerivedKeys.rootPub, gDerivedKeys.devicePub, gDerivedKeys.blockchainPub, gDerivedKeys.rootPub, gDerivedKeys.clientPub, gDerivedKeys.blockchainPub,
lastBlockSignature, rootSignature, createdAtMs); lastBlockSignature, rootSignature, createdAtMs);
std::vector<uint8_t> edRootData = buildEd25519InstructionData(rootSignature, gDerivedKeys.rootPub, unsignedHash); std::vector<uint8_t> edRootData = buildEd25519InstructionData(rootSignature, gDerivedKeys.rootPub, unsignedHash);
std::vector<uint8_t> edBchData = buildEd25519InstructionData(lastBlockSignature, gDerivedKeys.blockchainPub, lastBlockHash); std::vector<uint8_t> edBchData = buildEd25519InstructionData(lastBlockSignature, gDerivedKeys.blockchainPub, lastBlockHash);
@ -1269,7 +1272,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
std::vector<uint8_t> message = buildLegacyMessage( std::vector<uint8_t> message = buildLegacyMessage(
recentBlockhash, recentBlockhash,
gDerivedKeys.devicePub, gDerivedKeys.clientPub,
userPda, userPda,
inflowVault, inflowVault,
economyConfig, economyConfig,
@ -1277,7 +1280,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
edBchData, edBchData,
createData); createData);
uint8_t txSignature[64]; uint8_t txSignature[64];
if (!signMessageEd25519(message, gDerivedKeys.deviceSk, txSignature)) { if (!signMessageEd25519(message, gDerivedKeys.clientSk, txSignature)) {
messageOut = "Не удалось подписать Solana-транзакцию"; messageOut = "Не удалось подписать Solana-транзакцию";
return false; return false;
} }
@ -2107,7 +2110,7 @@ static void drawConfirmScreen() {
String text = "Выполнить действие?"; String text = "Выполнить действие?";
if (gConfirmTarget == CONFIRM_REGISTER) { if (gConfirmTarget == CONFIRM_REGISTER) {
title = "Регистрация"; title = "Регистрация";
text = "Отправить create_user_pda в Solana через device key этого устройства?"; text = "Отправить create_user_pda в Solana через client key этого устройства?";
} else if (gConfirmTarget == CONFIRM_CLEAR_ACCOUNT) { } else if (gConfirmTarget == CONFIRM_CLEAR_ACCOUNT) {
title = "Очистка"; title = "Очистка";
text = "Удалить секрет, кошелёк и статус регистрации?"; text = "Удалить секрет, кошелёк и статус регистрации?";

View File

@ -70,11 +70,11 @@
Фоновая логика: Фоновая логика:
- пока открыт `HOME`, экран сам обновляется примерно раз в секунду; - пока открыт `HOME`, экран сам обновляется примерно раз в секунду;
- при наличии `login + secret + homeserver` и Wi-Fi устройство читает Solana `user_pda` напрямую через RPC; - при наличии `login + secret + homeserver` и Wi-Fi устройство читает Solana `user_pda` напрямую через RPC;
- сравниваются `root key`, `blockchain key`, `device key` и `homeserver` session-запись типа `100`; - сравниваются `root key`, `blockchain key`, `client key` и `homeserver` session-запись типа `100`;
- для строки `SHiNE:` устройство держит отдельную WebSocket-сессию с сервером SHiNE: - для строки `SHiNE:` устройство держит отдельную WebSocket-сессию с сервером SHiNE:
- авторизация через `AuthChallenge/CreateAuthSession` или `SessionChallenge/SessionLogin`; - авторизация через `AuthChallenge/CreateAuthSession` или `SessionChallenge/SessionLogin`;
- session key = публичный `homeserver key`; - session key = публичный `homeserver key`;
- подтверждение создания сессии подписывается `device key`; - подтверждение создания сессии подписывается `client key`;
- heartbeat выполняется `Ping` раз в минуту. - heartbeat выполняется `Ping` раз в минуту.
## SETTINGS_MENU ## SETTINGS_MENU
@ -150,12 +150,12 @@
- статусное сообщение; - статусное сообщение;
- текущий `Solana RPC` адрес; - текущий `Solana RPC` адрес;
- кнопку `SOLANA RPC`; - кнопку `SOLANA RPC`;
- текущий `Shine server` адрес; - текущий `SHiNE server login` или уже резолвленный адрес;
- кнопку `SHINE SERVER`. - кнопку `SHiNE SERVER LOGIN`, если обычный `user PDA` ещё не зарегистрирован.
Значения по умолчанию: Значения по умолчанию:
- Solana RPC: `https://api.devnet.solana.com` - Solana RPC: `https://api.devnet.solana.com`
- Shine server: `https://shineup.me` - SHiNE server login: `shineupme`
Нажатие на любую из двух кнопок открывает `TEXT_EDIT_SCREEN`. Нажатие на любую из двух кнопок открывает `TEXT_EDIT_SCREEN`.
@ -214,8 +214,8 @@
- `Root key priv (base58)`; - `Root key priv (base58)`;
- `Blockchain key (base58)`; - `Blockchain key (base58)`;
- `Blockchain key priv (base58)`; - `Blockchain key priv (base58)`;
- `Device key (base58)`; - `Client key (base58)`;
- `Device key priv (base58)`; - `Client key priv (base58)`;
- `Homeserver key (base58)`; - `Homeserver key (base58)`;
- `Homeserver key priv (base58)`; - `Homeserver key priv (base58)`;
- для каждого поля показывается формула derivation; - для каждого поля показывается формула derivation;
@ -229,7 +229,7 @@
Используется для: Используется для:
- пароля Wi-Fi; - пароля Wi-Fi;
- Solana RPC; - Solana RPC;
- Shine server. - SHiNE server login.
Показывает: Показывает:
- заголовок; - заголовок;
@ -291,7 +291,7 @@
Используется `Preferences` (NVS памяти ESP32): Используется `Preferences` (NVS памяти ESP32):
- `solana_rpc` - `solana_rpc`
- `shine_server` - `shine_server_login`
## Хранение аккаунта ## Хранение аккаунта

View File

@ -65,12 +65,13 @@
- `user pda address`; - `user pda address`;
- `registration signature`; - `registration signature`;
- `balance`; - `balance`;
- `server api url`; - `server login` для первичной привязки;
- `server rpc url`; - `resolved server api url` / `rpc url` / `ws url` после чтения PDA сервера;
- `server ws url`;
- флаги: - флаги:
`wifiReady`, `serversReady`, `secretReady`, `registered`, `online`. `wifiReady`, `serversReady`, `secretReady`, `registered`, `online`.
Для первой регистрации обычного `user PDA` устройство берёт `createdAtMs` из NTP прямо перед отправкой транзакции в Solana. При последующих обновлениях `user PDA` устройство так же берёт актуальный `updatedAtMs` из NTP перед отправкой update-транзакции. Дальше в `user PDA` сохраняется `accessServers`, где по умолчанию лежит `shineupme`.
## Правило серверной сессии SHiNE ## Правило серверной сессии SHiNE
При подключении к серверу `SHiNE` устройство должно авторизовываться как homeserver-сеанс: При подключении к серверу `SHiNE` устройство должно авторизовываться как homeserver-сеанс:
@ -86,7 +87,7 @@
Кнопка регистрации доступна только если одновременно выполнены условия: Кнопка регистрации доступна только если одновременно выполнены условия:
1. настроен и подтверждён `Wi-Fi`; 1. настроен и подтверждён `Wi-Fi`;
2. заполнены и подтверждены серверные адреса; 2. задан и подтверждён `SHiNE server login`;
3. задан логин; 3. задан логин;
4. сгенерирован или введён секрет; 4. сгенерирован или введён секрет;
5. баланс кошелька не меньше `0.20 SOL`; 5. баланс кошелька не меньше `0.20 SOL`;
@ -178,7 +179,7 @@
- вместо старых двух кнопок `баланс` и `QR` показывается одна широкая кнопка; - вместо старых двух кнопок `баланс` и `QR` показывается одна широкая кнопка;
- текст кнопки: `Wallet: <selected wallet name>`; - текст кнопки: `Wallet: <selected wallet name>`;
- доступные имена: - доступные имена:
- `DeviceKey` - `ClientKey`
- `RootKey` - `RootKey`
- либо сохранённое имя `custom`-кошелька; - либо сохранённое имя `custom`-кошелька;
- после старта устройства баланс активного выбранного кошелька пытается загрузиться автоматически, если уже есть секрет и `Wi-Fi`; - после старта устройства баланс активного выбранного кошелька пытается загрузиться автоматически, если уже есть секрет и `Wi-Fi`;
@ -429,7 +430,7 @@
- верхняя кнопка открывает экран выбора кошелька `WALLET_SELECT`; - верхняя кнопка открывает экран выбора кошелька `WALLET_SELECT`;
- `Показать баланс кошелька` читает реальный баланс именно активного выбранного кошелька из `Solana RPC`; - `Показать баланс кошелька` читает реальный баланс именно активного выбранного кошелька из `Solana RPC`;
- `Показать QR-код кошелька` открывает экран `WALLET_QR`; - `Показать QR-код кошелька` открывает экран `WALLET_QR`;
- этот экран не меняет логику Solana-регистрации пользователя: on-chain регистрация и проверки `PDA` по-прежнему завязаны на `device key`. - этот экран не меняет логику Solana-регистрации пользователя: on-chain регистрация и проверки `PDA` по-прежнему завязаны на `client key`.
## Экран WALLET_SELECT ## Экран WALLET_SELECT
@ -437,14 +438,14 @@
- строку `Current: <selected wallet name>`; - строку `Current: <selected wallet name>`;
- три кнопки выбора: - три кнопки выбора:
- `DeviceKey` - `ClientKey`
- `RootKey` - `RootKey`
- `Custom` или `Custom: <имя>`; - `Custom` или `Custom: <имя>`;
- у текущего выбора видна галочка. - у текущего выбора видна галочка.
Поведение: Поведение:
- `DeviceKey` активирует кошелёк, выведенный из suffix `dev.key`; - `ClientKey` активирует кошелёк, выведенный из suffix `client.key`;
- `RootKey` активирует кошелёк, выведенный из suffix `root.key`; - `RootKey` активирует кошелёк, выведенный из suffix `root.key`;
- `Custom` использует derivation: - `Custom` использует derivation:
`sha256(base64(secret32) + "|wallet." + customName)`; `sha256(base64(secret32) + "|wallet." + customName)`;
@ -533,7 +534,7 @@
- строку `Session: <platform/name>`; - строку `Session: <platform/name>`;
- строку `Kind: Client session` или `Kind: Wallet session`; - строку `Kind: Client session` или `Kind: Wallet session`;
- пояснение: - пояснение:
- для client session: `Only device key will be transferred. No additional keys will be sent.` - для client session: `Only client key will be transferred. No additional keys will be sent.`
- для wallet session: `No keys will be transferred.` - для wallet session: `No keys will be transferred.`
Кнопки: Кнопки:
@ -544,7 +545,7 @@
Поведение: Поведение:
- `YES` подтверждает заявку: - `YES` подтверждает заявку:
- для client session устройство передаёт только `device key`; - для client session устройство передаёт только `client key`;
- для wallet session устройство выпускает отдельную `wallet-session` без передачи ключей; - для wallet session устройство выпускает отдельную `wallet-session` без передачи ключей;
- `NO` отклоняет заявку; - `NO` отклоняет заявку;
- после любого решения устройство возвращается в список `REQUESTS` и обновляет его; - после любого решения устройство возвращается в список `REQUESTS` и обновляет его;
@ -628,7 +629,7 @@
2. открыть `Подключение -> Wi-Fi`; 2. открыть `Подключение -> Wi-Fi`;
3. ввести `SSID` и пароль, нажать `Проверить`; 3. ввести `SSID` и пароль, нажать `Проверить`;
4. открыть `Подключение -> Серверы`; 4. открыть `Подключение -> Серверы`;
5. проверить или задать серверные адреса; 5. проверить или задать `SHiNE server login` (по умолчанию `shineupme`);
6. открыть `Аккаунт`; 6. открыть `Аккаунт`;
7. ввести логин; 7. ввести логин;
8. задать имя homeserver; 8. задать имя homeserver;
@ -637,14 +638,15 @@
11. при необходимости пополнить баланс; 11. при необходимости пополнить баланс;
12. вернуться на `HOME`; 12. вернуться на `HOME`;
13. нажать `REGISTER ACCOUNT`; 13. нажать `REGISTER ACCOUNT`;
14. на экране проверки ещё раз увидеть `login`, статус свободного `PDA`, баланс, `homeserver1` и при необходимости сообщение о неподключённом `Wi-Fi`; 14. на экране проверки ещё раз увидеть `login`, статус свободного `PDA`, баланс, `homeserver1`, серверный login и при необходимости сообщение о неподключённом `Wi-Fi`;
15. нажать `ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ`; 15. нажать `ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ`;
16. после завершения увидеть либо экран успеха с `user_pda` и `tx signature`, либо подробную ошибку; 16. после завершения увидеть либо экран успеха с `user_pda` и `tx signature`, либо подробную ошибку;
17. после успешной регистрации увидеть статус `Homeserver активен`. 17. после успешной регистрации увидеть статус `Homeserver активен`.
Примечание: Примечание:
- устройство реально отправляет `create_user_pda` в `shine_users`, а после подтверждения сохраняет `PDA` и `tx signature`. - устройство реально отправляет `create_user_pda` в `shine_users`, а после подтверждения сохраняет `PDA` и `tx signature`;
- при первой регистрации для обычного `user PDA` не заполняется `serverAddress`, а `accessServers` получает `shineupme` или другой выбранный `SHiNE server login`.
## Сценарий входящего запроса ## Сценарий входящего запроса

View File

@ -7,7 +7,7 @@ Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login.
- создать `wallet-session` через `StartTrustedDeviceLogin`; - создать `wallet-session` через `StartTrustedDeviceLogin`;
- показать код подключения; - показать код подключения;
- дождаться подтверждения на доверенном устройстве; - дождаться подтверждения на доверенном устройстве;
- принять `session-only` payload без передачи `deviceKey/rootKey/blockchainKey`; - принять `session-only` payload без передачи `clientKey/rootKey/blockchainKey`;
- сохранить `sessionPriv/sessionKey/sessionId` в локальном хранилище plugin; - сохранить `sessionPriv/sessionKey/sessionId` в локальном хранилище plugin;
- восстанавливать session через `SessionChallenge -> SessionLogin`; - восстанавливать session через `SessionChallenge -> SessionLogin`;
- держать wallet-state в `background service worker`, а side panel использовать как UI. - держать wallet-state в `background service worker`, а side panel использовать как UI.

View File

@ -30,6 +30,9 @@ const state = {
devicesResolvedAtMs: 0, devicesResolvedAtMs: 0,
}, },
currentWallet: null, currentWallet: null,
pendingApprovals: [],
siteApprovalChain: Promise.resolve(),
sessionAttachInProgress: false,
statusText: '', statusText: '',
statusKind: 'info', statusKind: 'info',
}; };
@ -69,6 +72,55 @@ function setStatus(message = '', kind = 'info') {
state.statusKind = kind === 'error' ? 'error' : 'info'; state.statusKind = kind === 'error' ? 'error' : 'info';
} }
function makePendingApprovalSnapshot(payload = {}) {
const pendingId = String(payload?.id || '').trim();
const pendingApprovals = Array.isArray(state.pendingApprovals) ? state.pendingApprovals : [];
const queueIndex = pendingApprovals.findIndex((item) => String(item?.id || '').trim() === pendingId);
const queueLength = pendingApprovals.length;
return {
id: pendingId,
kind: String(payload?.kind || 'sign_transaction').trim() || 'sign_transaction',
origin: String(payload?.origin || '').trim(),
publicKeyBase58: String(payload?.publicKeyBase58 || '').trim(),
comment: String(payload?.comment || '').trim(),
createdAtMs: Number(payload?.createdAtMs || Date.now()),
status: String(payload?.status || 'queued').trim() || 'queued',
queuePosition: queueIndex >= 0 ? queueIndex + 1 : 1,
queueLength: queueLength || 1,
transactionSummary: payload?.transactionSummary && typeof payload.transactionSummary === 'object'
? { ...payload.transactionSummary }
: null,
};
}
function getCurrentPendingApproval() {
return Array.isArray(state.pendingApprovals) && state.pendingApprovals.length
? state.pendingApprovals[0]
: null;
}
function removePendingApproval(pendingId, { rejectError = null } = {}) {
const pendingApprovals = Array.isArray(state.pendingApprovals) ? state.pendingApprovals : [];
const index = pendingApprovals.findIndex((item) => String(item?.id || '').trim() === String(pendingId || '').trim());
if (index < 0) return;
const [pending] = pendingApprovals.splice(index, 1);
if (pending.timeoutId) {
clearTimeout(pending.timeoutId);
}
if (rejectError && pending.abortController) {
try {
pending.abortController.abort(rejectError);
} catch {}
}
}
async function openSidePanelForSender(sender) {
if (!chrome.sidePanel?.open || !sender?.tab?.id) return;
try {
await chrome.sidePanel.open({ tabId: sender.tab.id });
} catch {}
}
function stopPoll() { function stopPoll() {
if (state.pollTimer) { if (state.pollTimer) {
clearTimeout(state.pollTimer); clearTimeout(state.pollTimer);
@ -103,7 +155,10 @@ async function loadStateFromStorage() {
login: String(settings?.login || '').trim(), login: String(settings?.login || '').trim(),
connectedOrigins: Array.isArray(settings?.connectedOrigins) ? settings.connectedOrigins.map((item) => normalizeOrigin(item)).filter(Boolean) : [], connectedOrigins: Array.isArray(settings?.connectedOrigins) ? settings.connectedOrigins.map((item) => normalizeOrigin(item)).filter(Boolean) : [],
}; };
state.activeSession = await loadSessionMaterial(); const storedSession = await loadSessionMaterial();
if (storedSession || !state.sessionAttachInProgress) {
state.activeSession = storedSession;
}
state.walletProfile = state.activeSession?.walletProfile || null; state.walletProfile = state.activeSession?.walletProfile || null;
state.signing = { state.signing = {
selectedDeviceName: String(state.activeSession?.selectedDeviceName || ''), selectedDeviceName: String(state.activeSession?.selectedDeviceName || ''),
@ -317,18 +372,30 @@ async function attachApprovedSession(payload) {
throw new Error('Получен неполный session-only payload'); throw new Error('Получен неполный session-only payload');
} }
await clearSessionMaterial(); state.sessionAttachInProgress = true;
state.activeSession = sessionRecord; try {
await hydrateWalletProfile(login); state.activeSession = sessionRecord;
await saveActiveSessionRecord(); state.walletProfile = null;
await persistSettings({ state.currentWallet = null;
login: sessionRecord.login, state.signing = {
serverLogin: sessionRecord.serverLogin, ...state.signing,
serverHttp: sessionRecord.serverHttp, selectedDeviceName: '',
serverUrl: sessionRecord.serverUrl, devicesResolvedAtMs: 0,
}); };
state.connectionOnline = false; await saveActiveSessionRecord();
state.currentWallet = null; await hydrateWalletProfile(login);
await saveActiveSessionRecord();
await persistSettings({
login: sessionRecord.login,
serverLogin: sessionRecord.serverLogin,
serverHttp: sessionRecord.serverHttp,
serverUrl: sessionRecord.serverUrl,
});
state.connectionOnline = false;
state.currentWallet = null;
} finally {
state.sessionAttachInProgress = false;
}
} }
async function pollPairingStatus() { async function pollPairingStatus() {
@ -506,7 +573,7 @@ async function resolveSelectedHomeserverSession() {
return selectedDevice; return selectedDevice;
} }
async function callWalletRpc(requestData, timeoutMs = 8000) { async function callWalletRpc(requestData, timeoutMs = 8000, abortSignal = null) {
const selectedDevice = await resolveSelectedHomeserverSession(); const selectedDevice = await resolveSelectedHomeserverSession();
const resumed = await resumeActiveSession({ keepConnected: true }); const resumed = await resumeActiveSession({ keepConnected: true });
if (!resumed.ok) { if (!resumed.ok) {
@ -523,22 +590,46 @@ async function callWalletRpc(requestData, timeoutMs = 8000) {
try { try {
const response = await new Promise((resolve, reject) => { const response = await new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => { let settled = false;
let timeoutId = 0;
let off = () => {};
let removeAbortListener = () => {};
const cleanup = () => {
if (settled) return;
settled = true;
if (timeoutId) clearTimeout(timeoutId);
off(); off();
removeAbortListener();
};
timeoutId = setTimeout(() => {
cleanup();
reject(new Error('Таймаут ответа от ESP32.')); reject(new Error('Таймаут ответа от ESP32.'));
}, timeoutMs); }, timeoutMs);
const off = ensureApi().onEvent('IncomingCallSignal', (evt) => { off = ensureApi().onEvent('IncomingCallSignal', (evt) => {
const eventPayload = evt?.payload || {}; const eventPayload = evt?.payload || {};
if (String(eventPayload?.callId || '') !== callId) return; if (String(eventPayload?.callId || '') !== callId) return;
if (Number(eventPayload?.type || 0) !== WALLET_RPC_RESPONSE_TYPE) return; if (Number(eventPayload?.type || 0) !== WALLET_RPC_RESPONSE_TYPE) return;
clearTimeout(timeoutId); cleanup();
off();
try { try {
resolve(JSON.parse(String(eventPayload?.data || '{}'))); resolve(JSON.parse(String(eventPayload?.data || '{}')));
} catch { } catch {
reject(new Error('ESP32 вернул некорректный JSON.')); reject(new Error('ESP32 вернул некорректный JSON.'));
} }
}); });
if (abortSignal) {
const onAbort = () => {
cleanup();
reject(abortSignal.reason instanceof Error ? abortSignal.reason : new Error('Ожидание подписи отменено.'));
};
if (abortSignal.aborted) {
onAbort();
return;
}
abortSignal.addEventListener('abort', onAbort, { once: true });
removeAbortListener = () => {
abortSignal.removeEventListener('abort', onAbort);
};
}
ensureApi().callSignalToSession({ ensureApi().callSignalToSession({
toLogin: state.activeSession.login, toLogin: state.activeSession.login,
targetSessionId: selectedDevice.activeSessionId, targetSessionId: selectedDevice.activeSessionId,
@ -546,8 +637,7 @@ async function callWalletRpc(requestData, timeoutMs = 8000) {
type: WALLET_RPC_REQUEST_TYPE, type: WALLET_RPC_REQUEST_TYPE,
data: JSON.stringify(payload), data: JSON.stringify(payload),
}).catch((error) => { }).catch((error) => {
clearTimeout(timeoutId); cleanup();
off();
reject(error); reject(error);
}); });
}); });
@ -563,11 +653,13 @@ function verifyWalletAgainstPda(wallet) {
const type = String(wallet?.type || '').trim(); const type = String(wallet?.type || '').trim();
const pub = String(wallet?.publicKeyBase58 || '').trim(); const pub = String(wallet?.publicKeyBase58 || '').trim();
const rootKey = String(state.walletProfile?.publicKeys?.rootKeyBase58 || '').trim(); const rootKey = String(state.walletProfile?.publicKeys?.rootKeyBase58 || '').trim();
const deviceKey = String(state.walletProfile?.publicKeys?.deviceKeyBase58 || '').trim(); const clientKey = String(
if (type === 'dev.key') { state.walletProfile?.publicKeys?.clientKeyBase58 || '',
).trim();
if (type === 'client.key') {
return { return {
verified: !!deviceKey && deviceKey === pub, verified: !!clientKey && clientKey === pub,
verificationText: deviceKey === pub ? 'Совпадает с deviceKey из PDA.' : 'Не совпадает с deviceKey из PDA.', verificationText: clientKey === pub ? 'Совпадает с clientKey из PDA.' : 'Не совпадает с clientKey из PDA.',
}; };
} }
if (type === 'root.key') { if (type === 'root.key') {
@ -604,6 +696,60 @@ async function requestCurrentWallet() {
return { ok: true, wallet: state.currentWallet }; return { ok: true, wallet: state.currentWallet };
} }
async function cancelPendingSiteApproval() {
const pending = getCurrentPendingApproval();
if (!pending) {
setStatus('Сейчас нет активного ожидания подписи.', 'info');
return { ok: true };
}
removePendingApproval(pending.id, {
rejectError: makeCodeError('User canceled request in extension.', 'USER_REJECTED'),
});
setStatus('Ожидание подписи отменено в расширении.', 'info');
return { ok: true };
}
async function markPendingSiteApprovalResolved(pendingId) {
removePendingApproval(pendingId);
}
function enqueueSiteApproval(work) {
const run = state.siteApprovalChain.then(work, work);
state.siteApprovalChain = run.catch(() => {});
return run;
}
async function activatePendingApproval(pending, sender = null) {
const abortController = new AbortController();
pending.status = 'active';
pending.abortController = abortController;
pending.timeoutId = setTimeout(() => {
removePendingApproval(pending.id, {
rejectError: makeCodeError('Signing request timed out in extension.', 'USER_REJECTED'),
});
setStatus('Ожидание подписи истекло в расширении.', 'error');
}, 120000);
setStatus(`Сайт ${pending.origin} запросил подпись. Подтвердите или отмените на доверенном устройстве.`, 'info');
await openSidePanelForSender(sender);
return pending;
}
function beginSiteTransactionFlow(payload = {}) {
const pending = makePendingApprovalSnapshot({
...payload,
kind: 'sign_transaction',
id: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
createdAtMs: Date.now(),
status: 'queued',
});
state.pendingApprovals.push({
...pending,
timeoutId: 0,
abortController: null,
});
return pending;
}
async function siteConnect({ origin, onlyIfTrusted = false } = {}) { async function siteConnect({ origin, onlyIfTrusted = false } = {}) {
const normalizedOrigin = normalizeOrigin(origin); const normalizedOrigin = normalizeOrigin(origin);
if (!normalizedOrigin) { if (!normalizedOrigin) {
@ -634,7 +780,7 @@ async function siteDisconnect({ origin } = {}) {
return { ok: true }; return { ok: true };
} }
async function siteSignTransaction({ origin, publicKeyBase58, transactionBase64, comment } = {}) { async function siteSignTransaction({ origin, publicKeyBase58, transactionBase64, comment, transactionSummary } = {}, sender = null) {
const normalizedOrigin = normalizeOrigin(origin); const normalizedOrigin = normalizeOrigin(origin);
if (!normalizedOrigin) { if (!normalizedOrigin) {
throw makeCodeError('Site origin is missing.', 'BAD_ORIGIN'); throw makeCodeError('Site origin is missing.', 'BAD_ORIGIN');
@ -647,29 +793,44 @@ async function siteSignTransaction({ origin, publicKeyBase58, transactionBase64,
if (!cleanPub || !cleanTx) { if (!cleanPub || !cleanTx) {
throw makeCodeError('Transaction payload is incomplete.', 'BAD_REQUEST'); throw makeCodeError('Transaction payload is incomplete.', 'BAD_REQUEST');
} }
const requestId = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; const pending = beginSiteTransactionFlow({
const signComment = String(comment || '').trim() || `Site ${normalizedOrigin} requested transaction signature`; origin: normalizedOrigin,
const { response } = await callWalletRpc({
v: 1,
operation: 'sign_transaction',
requestId,
publicKeyBase58: cleanPub, publicKeyBase58: cleanPub,
transactionBase64: cleanTx, comment: String(comment || '').trim(),
comment: signComment, transactionSummary: transactionSummary || null,
}, 120000); });
if (!response?.ok) { return enqueueSiteApproval(async () => {
const errorCode = String(response?.error || 'unknown_error').trim().toUpperCase(); await activatePendingApproval(getCurrentPendingApproval() || pending, sender);
if (errorCode === 'REJECTED_BY_USER') { const activePending = getCurrentPendingApproval() || pending;
throw makeCodeError('User rejected transaction signature on ESP32.', 'USER_REJECTED'); const requestId = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const signComment = String(comment || '').trim() || `Site ${normalizedOrigin} requested transaction signature`;
try {
const { response } = await callWalletRpc({
v: 1,
operation: 'sign_transaction',
requestId,
publicKeyBase58: cleanPub,
transactionBase64: cleanTx,
comment: signComment,
}, 120000, activePending.abortController?.signal || null);
if (!response?.ok) {
const errorCode = String(response?.error || 'unknown_error').trim().toUpperCase();
if (errorCode === 'REJECTED_BY_USER') {
throw makeCodeError('User rejected transaction signature on ESP32.', 'USER_REJECTED');
}
throw makeCodeError(`ESP32 rejected transaction signature: ${String(response?.error || 'unknown_error')}`, errorCode || 'RPC_REJECTED');
}
setStatus(`Подпись для ${normalizedOrigin} завершена.`, 'info');
return {
ok: true,
publicKeyBase58: String(response?.publicKeyBase58 || cleanPub).trim(),
signedTransactionBase64: String(response?.signedTransactionBase64 || '').trim(),
signatureBase58: String(response?.signatureBase58 || '').trim(),
};
} finally {
await markPendingSiteApprovalResolved(activePending.id);
} }
throw makeCodeError(`ESP32 rejected transaction signature: ${String(response?.error || 'unknown_error')}`, errorCode || 'RPC_REJECTED'); });
}
return {
ok: true,
publicKeyBase58: String(response?.publicKeyBase58 || cleanPub).trim(),
signedTransactionBase64: String(response?.signedTransactionBase64 || '').trim(),
signatureBase58: String(response?.signatureBase58 || '').trim(),
};
} }
function snapshot() { function snapshot() {
@ -686,6 +847,7 @@ function snapshot() {
connectionOnline: !!state.activeSession, connectionOnline: !!state.activeSession,
walletProfile: state.walletProfile ? { ...state.walletProfile } : null, walletProfile: state.walletProfile ? { ...state.walletProfile } : null,
currentWallet: state.currentWallet ? { ...state.currentWallet } : null, currentWallet: state.currentWallet ? { ...state.currentWallet } : null,
pendingApproval: getCurrentPendingApproval() ? makePendingApprovalSnapshot(getCurrentPendingApproval()) : null,
signing: { ...state.signing }, signing: { ...state.signing },
status: { status: {
text: state.statusText, text: state.statusText,
@ -747,6 +909,11 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
sendResponse({ ok: true, result, state: snapshot() }); sendResponse({ ok: true, result, state: snapshot() });
return; return;
} }
if (type === 'wallet:cancelPendingSiteApproval') {
const result = await cancelPendingSiteApproval();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:siteConnect') { if (type === 'wallet:siteConnect') {
const result = await siteConnect(message?.payload || {}); const result = await siteConnect(message?.payload || {});
sendResponse({ ok: true, result, state: snapshot() }); sendResponse({ ok: true, result, state: snapshot() });
@ -758,7 +925,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
return; return;
} }
if (type === 'wallet:siteSignTransaction') { if (type === 'wallet:siteSignTransaction') {
const result = await siteSignTransaction(message?.payload || {}); const result = await siteSignTransaction(message?.payload || {}, _sender);
sendResponse({ ok: true, result, state: snapshot() }); sendResponse({ ok: true, result, state: snapshot() });
return; return;
} }

View File

@ -1,5 +1,6 @@
const PAGE_REQUEST = 'shine-wallet-page-request'; const PAGE_REQUEST = 'shine-wallet-page-request';
const PAGE_RESPONSE = 'shine-wallet-page-response'; const PAGE_RESPONSE = 'shine-wallet-page-response';
const PAGE_MESSAGE_TARGET_ORIGIN = '*';
function injectProviderBridge() { function injectProviderBridge() {
const root = document.head || document.documentElement; const root = document.head || document.documentElement;
@ -20,14 +21,21 @@ function respondToPage(id, ok, result, error, code) {
result: result || null, result: result || null,
error: error ? String(error) : '', error: error ? String(error) : '',
code: code ? String(code) : '', code: code ? String(code) : '',
}, window.location.origin); }, PAGE_MESSAGE_TARGET_ORIGIN);
} }
function sendRuntimeMessage(type, payload = {}) { function sendRuntimeMessage(type, payload = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type, payload }, (response) => { chrome.runtime.sendMessage({ type, payload }, (response) => {
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message || 'Runtime message failed')); const raw = String(chrome.runtime.lastError.message || 'Runtime message failed');
if (/Extension context invalidated/i.test(raw)) {
const error = new Error('Расширение было перезагружено или отключено. Обновите страницу и откройте кошелёк заново.');
error.code = 'EXTENSION_CONTEXT_INVALIDATED';
reject(error);
return;
}
reject(new Error(raw));
return; return;
} }
if (!response?.ok) { if (!response?.ok) {
@ -71,6 +79,7 @@ window.addEventListener('message', (event) => {
publicKeyBase58: String(params?.publicKeyBase58 || '').trim(), publicKeyBase58: String(params?.publicKeyBase58 || '').trim(),
transactionBase64: String(params?.transactionBase64 || '').trim(), transactionBase64: String(params?.transactionBase64 || '').trim(),
comment: String(params?.comment || '').trim(), comment: String(params?.comment || '').trim(),
transactionSummary: params?.transactionSummary || null,
}); });
respondToPage(id, true, response.result || null); respondToPage(id, true, response.result || null);
return; return;

View File

@ -2,7 +2,7 @@ import { base64ToBytes } from './crypto-utils.js';
import { PublicKey } from './vendor/solana-publickey-bundle.js'; import { PublicKey } from './vendor/solana-publickey-bundle.js';
const SOLANA_ENDPOINT_DEFAULT = 'https://api.devnet.solana.com'; const SOLANA_ENDPOINT_DEFAULT = 'https://api.devnet.solana.com';
const SHINE_USERS_PROGRAM_ID = 'FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm'; const SHINE_USERS_PROGRAM_ID = '3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ';
const SHINE_USERS_USER_PDA_SEED_PREFIX = 'user_login='; const SHINE_USERS_USER_PDA_SEED_PREFIX = 'user_login=';
const DEFAULT_SHINE_SERVER_LOGIN = 'shineupme'; const DEFAULT_SHINE_SERVER_LOGIN = 'shineupme';
const DEFAULT_SHINE_SERVER_ADDRESS = 'shineup.me'; const DEFAULT_SHINE_SERVER_ADDRESS = 'shineup.me';
@ -69,8 +69,9 @@ function parseServerFieldsFromUserPda(dataBytes) {
let isServer = false; let isServer = false;
let serverAddress = ''; let serverAddress = '';
let accessServers = []; let accessServers = [];
let recoveryKey32 = null;
let rootKey32 = null; let rootKey32 = null;
let deviceKey32 = null; let clientKey32 = null;
let blockchainKey32 = null; let blockchainKey32 = null;
let blockchainName = ''; let blockchainName = '';
let homeserverSessions = []; let homeserverSessions = [];
@ -79,10 +80,11 @@ function parseServerFieldsFromUserPda(dataBytes) {
const blockType = readU8(bytes, cursorRef); const blockType = readU8(bytes, cursorRef);
cursorRef.value += 1; // block_version cursorRef.value += 1; // block_version
if (blockType === 1 || blockType === 2) { if (blockType === 0 || blockType === 1 || blockType === 2) {
const key32 = readBytes(bytes, cursorRef, 32); const key32 = readBytes(bytes, cursorRef, 32);
if (blockType === 0) recoveryKey32 = key32;
if (blockType === 1) rootKey32 = key32; if (blockType === 1) rootKey32 = key32;
if (blockType === 2) deviceKey32 = key32; if (blockType === 2) clientKey32 = key32;
continue; continue;
} }
if (blockType === 3) { if (blockType === 3) {
@ -150,8 +152,9 @@ function parseServerFieldsFromUserPda(dataBytes) {
serverAddress: normalizeHostLike(serverAddress), serverAddress: normalizeHostLike(serverAddress),
accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean), accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean),
publicKeys: { publicKeys: {
recoveryKeyBase58: recoveryKey32 ? new PublicKey(recoveryKey32).toBase58() : '',
rootKeyBase58: rootKey32 ? new PublicKey(rootKey32).toBase58() : '', rootKeyBase58: rootKey32 ? new PublicKey(rootKey32).toBase58() : '',
deviceKeyBase58: deviceKey32 ? new PublicKey(deviceKey32).toBase58() : '', clientKeyBase58: clientKey32 ? new PublicKey(clientKey32).toBase58() : '',
blockchainKeyBase58: blockchainKey32 ? new PublicKey(blockchainKey32).toBase58() : '', blockchainKeyBase58: blockchainKey32 ? new PublicKey(blockchainKey32).toBase58() : '',
blockchainName, blockchainName,
}, },

View File

@ -198,6 +198,12 @@ select {
gap: 8px; gap: 8px;
} }
.detail-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.device-row { .device-row {
padding: 8px 10px; padding: 8px 10px;
border: 1px solid #243446; border: 1px solid #243446;
@ -205,6 +211,30 @@ select {
background: #0d141d; background: #0d141d;
} }
.detail-row {
display: grid;
gap: 4px;
padding: 8px 10px;
border: 1px solid #243446;
border-radius: 8px;
background: #0d141d;
}
.detail-label {
font-size: 12px;
color: #9aabbd;
}
.detail-value {
color: #e8eef6;
word-break: break-word;
}
.detail-value.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #bed5f5;
}
.device-state { .device-state {
font-size: 12px; font-size: 12px;
text-transform: lowercase; text-transform: lowercase;

View File

@ -18,7 +18,6 @@
</div> </div>
<p id="server-login-info" class="muted small">Сервер SHiNE: —</p> <p id="server-login-info" class="muted small">Сервер SHiNE: —</p>
<p id="server-address" class="muted small">Адрес: —</p>
<div id="connect-card" class="card"> <div id="connect-card" class="card">
<div class="card-title">Подключение</div> <div class="card-title">Подключение</div>
@ -50,9 +49,6 @@
<div id="session-card" class="card hidden"> <div id="session-card" class="card hidden">
<div class="card-title">Подключено</div> <div class="card-title">Подключено</div>
<div class="summary-row"><span>Логин</span><strong id="session-login"></strong></div> <div class="summary-row"><span>Логин</span><strong id="session-login"></strong></div>
<div class="summary-row"><span>Session</span><code id="session-id"></code></div>
<div class="summary-row"><span>Тип</span><strong id="session-type">wallet</strong></div>
<div class="summary-row"><span>deviceKey</span><code id="device-key-short"></code></div>
<div class="actions"> <div class="actions">
<button id="resume-btn" class="btn secondary" type="button">Проверить session</button> <button id="resume-btn" class="btn secondary" type="button">Проверить session</button>
<button id="disconnect-btn" class="btn danger" type="button">Отключить</button> <button id="disconnect-btn" class="btn danger" type="button">Отключить</button>
@ -72,6 +68,16 @@
</div> </div>
</div> </div>
<div id="pending-approval-card" class="card hidden">
<div class="card-title">Ожидается подпись</div>
<p id="pending-approval-subtitle" class="muted small">Сайт запросил подписание транзакции.</p>
<div id="pending-approval-details" class="device-list"></div>
<p class="muted small">Запрос уже отправлен на доверенное устройство. Здесь можно только отменить ожидание.</p>
<div class="actions">
<button id="cancel-pending-approval-btn" class="btn danger" type="button">Отменить</button>
</div>
</div>
<div id="wallet-result-card" class="card hidden"> <div id="wallet-result-card" class="card hidden">
<div class="card-title">Полученный кошелёк</div> <div class="card-title">Полученный кошелёк</div>
<div class="summary-row"><span>Тип</span><strong id="wallet-type"></strong></div> <div class="summary-row"><span>Тип</span><strong id="wallet-type"></strong></div>

View File

@ -2,7 +2,6 @@ import { formatPairingShortCode } from './js/lib/device-pairing.js';
const els = { const els = {
serverLoginInfo: document.querySelector('#server-login-info'), serverLoginInfo: document.querySelector('#server-login-info'),
serverAddress: document.querySelector('#server-address'),
loginInput: document.querySelector('#login-input'), loginInput: document.querySelector('#login-input'),
usePassword: document.querySelector('#use-password'), usePassword: document.querySelector('#use-password'),
passwordField: document.querySelector('#password-field'), passwordField: document.querySelector('#password-field'),
@ -17,9 +16,6 @@ const els = {
status: document.querySelector('#status'), status: document.querySelector('#status'),
sessionCard: document.querySelector('#session-card'), sessionCard: document.querySelector('#session-card'),
sessionLogin: document.querySelector('#session-login'), sessionLogin: document.querySelector('#session-login'),
sessionId: document.querySelector('#session-id'),
sessionType: document.querySelector('#session-type'),
deviceKeyShort: document.querySelector('#device-key-short'),
resumeBtn: document.querySelector('#resume-btn'), resumeBtn: document.querySelector('#resume-btn'),
refreshDevicesBtn: document.querySelector('#refresh-devices-btn'), refreshDevicesBtn: document.querySelector('#refresh-devices-btn'),
disconnectBtn: document.querySelector('#disconnect-btn'), disconnectBtn: document.querySelector('#disconnect-btn'),
@ -27,6 +23,10 @@ const els = {
deviceSelect: document.querySelector('#device-select'), deviceSelect: document.querySelector('#device-select'),
homeserverList: document.querySelector('#homeserver-list'), homeserverList: document.querySelector('#homeserver-list'),
requestWalletBtn: document.querySelector('#request-wallet-btn'), requestWalletBtn: document.querySelector('#request-wallet-btn'),
pendingApprovalCard: document.querySelector('#pending-approval-card'),
pendingApprovalSubtitle: document.querySelector('#pending-approval-subtitle'),
pendingApprovalDetails: document.querySelector('#pending-approval-details'),
cancelPendingApprovalBtn: document.querySelector('#cancel-pending-approval-btn'),
walletResultCard: document.querySelector('#wallet-result-card'), walletResultCard: document.querySelector('#wallet-result-card'),
walletType: document.querySelector('#wallet-type'), walletType: document.querySelector('#wallet-type'),
walletPubkey: document.querySelector('#wallet-pubkey'), walletPubkey: document.querySelector('#wallet-pubkey'),
@ -52,6 +52,7 @@ let state = {
selectedDeviceName: '', selectedDeviceName: '',
}, },
currentWallet: null, currentWallet: null,
pendingApproval: null,
status: { status: {
text: '', text: '',
kind: 'info', kind: 'info',
@ -107,13 +108,50 @@ function renderHomeserverList(items = []) {
}); });
} }
function renderPendingApproval(pendingApproval) {
els.pendingApprovalDetails.innerHTML = '';
if (!pendingApproval) return;
const summary = pendingApproval.transactionSummary || {};
const programs = Array.isArray(summary.programs) && summary.programs.length
? summary.programs.join(', ')
: 'не определены';
const details = [
{ label: 'Сайт', value: pendingApproval.origin || '—', mono: true },
{ label: 'Кошелёк', value: pendingApproval.publicKeyBase58 || '—', mono: true },
{ label: 'Очередь', value: `${pendingApproval.queuePosition || 1} из ${pendingApproval.queueLength || 1}` },
{ label: 'Комментарий', value: pendingApproval.comment || 'Транзакция запрошена сайтом' },
{ label: 'Тип', value: summary.kind || 'legacy' },
{ label: 'Инструкций', value: String(summary.instructionCount ?? 0) },
{ label: 'Программы', value: programs, mono: true },
];
if (summary.feePayer) {
details.push({ label: 'Fee payer', value: summary.feePayer, mono: true });
}
if (summary.recentBlockhash) {
details.push({ label: 'Blockhash', value: summary.recentBlockhash, mono: true });
}
for (const item of details) {
const row = document.createElement('div');
row.className = 'detail-row';
const label = document.createElement('div');
label.className = 'detail-label';
label.textContent = item.label;
const value = document.createElement('div');
value.className = `detail-value${item.mono ? ' mono' : ''}`;
value.textContent = item.value;
row.append(label, value);
els.pendingApprovalDetails.append(row);
}
}
function applyState(nextState) { function applyState(nextState) {
state = nextState || state; state = nextState || state;
const loginValue = String(state?.settings?.login || ''); const loginValue = String(state?.settings?.login || '');
const resolvedServerLogin = String(state?.settings?.serverLogin || '').trim(); const resolvedServerLogin = String(state?.settings?.serverLogin || '').trim();
const resolvedServerAddress = String(state?.settings?.serverHttp || '').trim(); const resolvedServerAddress = String(state?.settings?.serverHttp || '').trim();
els.serverLoginInfo.textContent = resolvedServerLogin ? `Сервер SHiNE: ${resolvedServerLogin}` : 'Сервер SHiNE: —'; els.serverLoginInfo.textContent = resolvedServerLogin && resolvedServerAddress
els.serverAddress.textContent = resolvedServerAddress ? `Адрес: ${resolvedServerAddress}` : 'Адрес: —'; ? `Сервер SHiNE: ${resolvedServerLogin} (${resolvedServerAddress})`
: 'Сервер SHiNE: —';
if (document.activeElement !== els.loginInput) { if (document.activeElement !== els.loginInput) {
els.loginInput.value = loginValue; els.loginInput.value = loginValue;
} }
@ -125,16 +163,15 @@ function applyState(nextState) {
const walletProfile = state?.walletProfile; const walletProfile = state?.walletProfile;
const signing = state?.signing || {}; const signing = state?.signing || {};
const currentWallet = state?.currentWallet || null; const currentWallet = state?.currentWallet || null;
const pendingApproval = state?.pendingApproval || null;
els.connectCard.classList.toggle('hidden', !!session); els.connectCard.classList.toggle('hidden', !!session);
els.sessionCard.classList.toggle('hidden', !session); els.sessionCard.classList.toggle('hidden', !session);
els.walletCard.classList.toggle('hidden', !session); els.walletCard.classList.toggle('hidden', !session);
els.pendingApprovalCard.classList.toggle('hidden', !pendingApproval);
if (session) { if (session) {
els.sessionLogin.textContent = session.login || '—'; els.sessionLogin.textContent = session.login || '—';
els.sessionId.textContent = session.sessionId || '—';
els.sessionType.textContent = String(session.sessionType || 50) === '50' ? 'wallet' : String(session.sessionType || '—');
els.deviceKeyShort.textContent = shortKey(walletProfile?.publicKeys?.deviceKeyBase58 || '');
} }
const homeservers = Array.isArray(walletProfile?.homeserverSessions) ? walletProfile.homeserverSessions : []; const homeservers = Array.isArray(walletProfile?.homeserverSessions) ? walletProfile.homeserverSessions : [];
@ -149,6 +186,19 @@ function applyState(nextState) {
renderHomeserverList(homeservers); renderHomeserverList(homeservers);
els.requestWalletBtn.disabled = !session || !signing.selectedDeviceName; els.requestWalletBtn.disabled = !session || !signing.selectedDeviceName;
if (pendingApproval) {
const queueSuffix = (pendingApproval.queueLength || 1) > 1
? ` В очереди ${pendingApproval.queueLength} транзакции.`
: '';
els.pendingApprovalSubtitle.textContent = pendingApproval.origin
? `Сайт ${pendingApproval.origin} запросил подписание транзакции.${queueSuffix}`
: `Сайт запросил подписание транзакции.${queueSuffix}`;
renderPendingApproval(pendingApproval);
} else {
els.pendingApprovalSubtitle.textContent = 'Сайт запросил подписание транзакции.';
els.pendingApprovalDetails.innerHTML = '';
}
if (currentWallet?.publicKeyBase58) { if (currentWallet?.publicKeyBase58) {
els.walletResultCard.classList.remove('hidden'); els.walletResultCard.classList.remove('hidden');
els.walletType.textContent = currentWallet.type || '—'; els.walletType.textContent = currentWallet.type || '—';
@ -313,6 +363,14 @@ async function copyWalletKey() {
} }
} }
async function cancelPendingApproval() {
try {
await sendMessage('wallet:cancelPendingSiteApproval');
} catch (error) {
setStatus(error.message || 'Не удалось отменить ожидание подписи.', 'error');
}
}
function startUiRefreshLoop() { function startUiRefreshLoop() {
stopUiRefreshLoop(); stopUiRefreshLoop();
refreshTimer = window.setInterval(() => { refreshTimer = window.setInterval(() => {
@ -347,6 +405,7 @@ function bindUi() {
els.deviceSelect.addEventListener('change', () => { void updateDeviceSelection(); }); els.deviceSelect.addEventListener('change', () => { void updateDeviceSelection(); });
els.requestWalletBtn.addEventListener('click', () => { void requestCurrentWallet(); }); els.requestWalletBtn.addEventListener('click', () => { void requestCurrentWallet(); });
els.copyWalletBtn.addEventListener('click', () => { void copyWalletKey(); }); els.copyWalletBtn.addEventListener('click', () => { void copyWalletKey(); });
els.cancelPendingApprovalBtn.addEventListener('click', () => { void cancelPendingApproval(); });
} }
async function init() { async function init() {

View File

@ -2,6 +2,7 @@ import { PublicKey, Transaction, VersionedTransaction } from './js/lib/vendor/so
const PAGE_REQUEST = 'shine-wallet-page-request'; const PAGE_REQUEST = 'shine-wallet-page-request';
const PAGE_RESPONSE = 'shine-wallet-page-response'; const PAGE_RESPONSE = 'shine-wallet-page-response';
const PAGE_MESSAGE_TARGET_ORIGIN = '*';
const STANDARD_REGISTER_EVENT = 'wallet-standard:register-wallet'; const STANDARD_REGISTER_EVENT = 'wallet-standard:register-wallet';
const STANDARD_APP_READY_EVENT = 'wallet-standard:app-ready'; const STANDARD_APP_READY_EVENT = 'wallet-standard:app-ready';
const SOLANA_CHAINS = ['solana:mainnet', 'solana:devnet', 'solana:testnet']; const SOLANA_CHAINS = ['solana:mainnet', 'solana:devnet', 'solana:testnet'];
@ -39,6 +40,45 @@ function createProviderError(message, code = '') {
return error; return error;
} }
function summarizeTransaction(transaction) {
const summary = {
kind: 'legacy',
instructionCount: 0,
accountCount: 0,
feePayer: '',
recentBlockhash: '',
programs: [],
};
if (!transaction) return summary;
const isVersioned = typeof transaction?.version === 'number' || transaction instanceof VersionedTransaction;
summary.kind = isVersioned ? `versioned:${String(transaction.version)}` : 'legacy';
summary.feePayer = String(transaction?.feePayer?.toBase58?.() || '').trim();
summary.recentBlockhash = String(transaction?.recentBlockhash || transaction?.message?.recentBlockhash || '').trim();
if (isVersioned) {
const message = transaction?.message || {};
const staticKeys = Array.isArray(message?.staticAccountKeys) ? message.staticAccountKeys : [];
const instructions = Array.isArray(message?.compiledInstructions) ? message.compiledInstructions : [];
summary.instructionCount = instructions.length;
summary.accountCount = staticKeys.length;
summary.programs = instructions
.map((instruction) => staticKeys[instruction?.programIdIndex]?.toBase58?.() || '')
.filter(Boolean)
.slice(0, 5);
return summary;
}
const instructions = Array.isArray(transaction?.instructions) ? transaction.instructions : [];
summary.instructionCount = instructions.length;
summary.accountCount = Array.isArray(transaction?.signatures) ? transaction.signatures.length : 0;
summary.programs = instructions
.map((instruction) => instruction?.programId?.toBase58?.() || '')
.filter(Boolean)
.slice(0, 5);
return summary;
}
function createRequest(method, params = {}) { function createRequest(method, params = {}) {
const id = `shine-wallet-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; const id = `shine-wallet-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -59,7 +99,7 @@ function createRequest(method, params = {}) {
id, id,
method, method,
params, params,
}, window.location.origin); }, PAGE_MESSAGE_TARGET_ORIGIN);
}); });
} }
@ -122,12 +162,6 @@ class ShineProviderCore {
async connect(options = {}) { async connect(options = {}) {
const onlyIfTrusted = !!options?.onlyIfTrusted || !!options?.silent; const onlyIfTrusted = !!options?.onlyIfTrusted || !!options?.silent;
if (!onlyIfTrusted) {
const confirmed = window.confirm(`Connect SHiNE Wallet to ${window.location.origin}?`);
if (!confirmed) {
throw createProviderError('User rejected wallet connection', 'USER_REJECTED');
}
}
const result = await createRequest('connect', { onlyIfTrusted }); const result = await createRequest('connect', { onlyIfTrusted });
const nextKey = new PublicKey(String(result?.publicKeyBase58 || '').trim()); const nextKey = new PublicKey(String(result?.publicKeyBase58 || '').trim());
this.publicKey = nextKey; this.publicKey = nextKey;
@ -157,10 +191,12 @@ class ShineProviderCore {
await this.connect(); await this.connect();
} }
const transactionBase64 = serializeTransactionBase64(transaction); const transactionBase64 = serializeTransactionBase64(transaction);
const transactionSummary = summarizeTransaction(transaction);
const result = await createRequest('signTransaction', { const result = await createRequest('signTransaction', {
publicKeyBase58: this.publicKeyBase58, publicKeyBase58: this.publicKeyBase58,
transactionBase64, transactionBase64,
comment: String(comment || '').trim() || `Site ${window.location.origin} requested transaction signature`, comment: String(comment || '').trim() || `Site ${window.location.origin} requested transaction signature`,
transactionSummary,
}); });
return deserializeSignedTransaction(String(result?.signedTransactionBase64 || ''), transaction); return deserializeSignedTransaction(String(result?.signedTransactionBase64 || ''), transaction);
} }
@ -169,10 +205,20 @@ class ShineProviderCore {
if (!this.publicKey) { if (!this.publicKey) {
await this.connect(); await this.connect();
} }
const transactionSummary = {
kind: 'raw-bytes',
instructionCount: 0,
accountCount: 0,
feePayer: this.publicKeyBase58,
recentBlockhash: '',
programs: [],
byteLength: Number(transactionBytes?.length || 0),
};
const result = await createRequest('signTransaction', { const result = await createRequest('signTransaction', {
publicKeyBase58: this.publicKeyBase58, publicKeyBase58: this.publicKeyBase58,
transactionBase64: bytesToBase64(transactionBytes), transactionBase64: bytesToBase64(transactionBytes),
comment: String(comment || '').trim() || `Site ${window.location.origin} requested transaction signature`, comment: String(comment || '').trim() || `Site ${window.location.origin} requested transaction signature`,
transactionSummary,
}); });
return base64ToBytes(String(result?.signedTransactionBase64 || '').trim()); return base64ToBytes(String(result?.signedTransactionBase64 || '').trim());
} }
@ -262,6 +308,15 @@ class ShineSolanaProvider {
return this.core.signTransaction(transaction); return this.core.signTransaction(transaction);
} }
async signAllTransactions(transactions = []) {
const list = Array.isArray(transactions) ? transactions : [];
const outputs = [];
for (const transaction of list) {
outputs.push(await this.core.signTransaction(transaction));
}
return outputs;
}
async request(args = {}) { async request(args = {}) {
const method = String(args?.method || ''); const method = String(args?.method || '');
const params = args?.params; const params = args?.params;
@ -275,6 +330,12 @@ class ShineSolanaProvider {
const tx = Array.isArray(params) ? params[0] : params?.transaction || params; const tx = Array.isArray(params) ? params[0] : params?.transaction || params;
return this.signTransaction(tx); return this.signTransaction(tx);
} }
if (method === 'signAllTransactions') {
const transactions = Array.isArray(params)
? params
: Array.isArray(params?.transactions) ? params.transactions : [];
return this.signAllTransactions(transactions);
}
throw createProviderError(`Unsupported request method: ${method}`, 'UNSUPPORTED_METHOD'); throw createProviderError(`Unsupported request method: ${method}`, 'UNSUPPORTED_METHOD');
} }
} }

View File

@ -42,7 +42,7 @@ shine-UI/server-ui.html
Для обновления — только root + device (blockchain-ключ не нужен). Для обновления — только root + device (blockchain-ключ не нужен).
Актуальные адреса программ Solana (devnet): Актуальные адреса программ Solana (devnet):
- `shine_users`: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` - `shine_users`: `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ`
- `shine_payments`: `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW` - `shine_payments`: `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW`
Подробнее: `Dev_Docs/Инициализация_Solana_регистрации/README.md` Подробнее: `Dev_Docs/Инициализация_Solana_регистрации/README.md`

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

View File

@ -12,10 +12,9 @@ public final class SolanaProgramsConfig {
public static final String SOLANA_RPC_URL = "https://api.devnet.solana.com"; public static final String SOLANA_RPC_URL = "https://api.devnet.solana.com";
// Программа регистрации пользователей (shine_users), задеплоена в devnet. // Программа регистрации пользователей (shine_users), задеплоена в devnet.
public static final String SHINE_USERS_PROGRAM_ID = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm"; public static final String SHINE_USERS_PROGRAM_ID = "3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ";
// Отдельно фиксируем адреса связанной инфраструктуры, чтобы UI/сервер ссылались одинаково. // Отдельно фиксируем адреса связанной инфраструктуры, чтобы UI/сервер ссылались одинаково.
public static final String SHINE_LOGIN_GUARD_PROGRAM_ID = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo"; public static final String SHINE_LOGIN_GUARD_PROGRAM_ID = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo";
public static final String SHINE_PAYMENTS_PROGRAM_ID = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW"; public static final String SHINE_PAYMENTS_PROGRAM_ID = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW";
} }

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

View File

@ -140,7 +140,7 @@ public final class DatabaseInitializer {
// 1. solana_users // 1. solana_users
// ВАЖНО: // ВАЖНО:
// - Все требуемые поля теперь лежат в solana_users: // - Все требуемые поля теперь лежат в solana_users:
// login, blockchain_name, solana_key, blockchain_key, device_key // login, blockchain_name, solana_key, blockchain_key, client_key
// - Поиск по login в DAO сделан case-insensitive. // - Поиск по login в DAO сделан case-insensitive.
// - Для защиты от дублей "Anya" и "anya" добавляем COLLATE NOCASE на PRIMARY KEY. // - Для защиты от дублей "Anya" и "anya" добавляем COLLATE NOCASE на PRIMARY KEY.
st.executeUpdate(""" st.executeUpdate("""
@ -149,7 +149,7 @@ public final class DatabaseInitializer {
blockchain_name TEXT NOT NULL, blockchain_name TEXT NOT NULL,
solana_key TEXT NOT NULL, solana_key TEXT NOT NULL,
blockchain_key TEXT NOT NULL, blockchain_key TEXT NOT NULL,
device_key TEXT NOT NULL client_key TEXT NOT NULL
); );
"""); """);
@ -238,7 +238,7 @@ public final class DatabaseInitializer {
param TEXT NOT NULL, param TEXT NOT NULL,
time_ms INTEGER NOT NULL, time_ms INTEGER NOT NULL,
value TEXT NOT NULL, value TEXT NOT NULL,
device_key TEXT, client_key TEXT,
signature TEXT, signature TEXT,
FOREIGN KEY (login) REFERENCES solana_users(login), FOREIGN KEY (login) REFERENCES solana_users(login),
UNIQUE (login, param) UNIQUE (login, param)

View File

@ -17,7 +17,7 @@ import java.util.List;
* - blockchain_name TEXT NOT NULL * - blockchain_name TEXT NOT NULL
* - solana_key TEXT NOT NULL * - solana_key TEXT NOT NULL
* - blockchain_key TEXT NOT NULL * - blockchain_key TEXT NOT NULL
* - device_key TEXT NOT NULL * - client_key TEXT NOT NULL
* *
* Правило работы с соединениями: * Правило работы с соединениями:
* - методы с Connection НЕ закрывают соединение * - методы с Connection НЕ закрывают соединение
@ -45,7 +45,7 @@ public final class SolanaUsersDAO {
public void insert(Connection c, SolanaUserEntry user) throws SQLException { public void insert(Connection c, SolanaUserEntry user) throws SQLException {
String sql = """ String sql = """
INSERT INTO solana_users ( INSERT INTO solana_users (
login, blockchain_name, solana_key, blockchain_key, device_key login, blockchain_name, solana_key, blockchain_key, client_key
) VALUES (?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?)
"""; """;
@ -54,7 +54,7 @@ public final class SolanaUsersDAO {
ps.setString(2, user.getBlockchainName()); ps.setString(2, user.getBlockchainName());
ps.setString(3, user.getSolanaKey()); ps.setString(3, user.getSolanaKey());
ps.setString(4, user.getBlockchainKey()); ps.setString(4, user.getBlockchainKey());
ps.setString(5, user.getDeviceKey()); ps.setString(5, user.getClientKey());
ps.executeUpdate(); ps.executeUpdate();
} }
} }
@ -126,7 +126,7 @@ public final class SolanaUsersDAO {
blockchain_name, blockchain_name,
solana_key, solana_key,
blockchain_key, blockchain_key,
device_key client_key
FROM solana_users FROM solana_users
WHERE LOWER(login) = LOWER(?) WHERE LOWER(login) = LOWER(?)
"""; """;
@ -155,7 +155,7 @@ public final class SolanaUsersDAO {
blockchain_name, blockchain_name,
solana_key, solana_key,
blockchain_key, blockchain_key,
device_key client_key
FROM solana_users FROM solana_users
WHERE blockchain_name = ? WHERE blockchain_name = ?
"""; """;
@ -184,7 +184,7 @@ public final class SolanaUsersDAO {
blockchain_name, blockchain_name,
solana_key, solana_key,
blockchain_key, blockchain_key,
device_key client_key
FROM solana_users FROM solana_users
WHERE LOWER(login) LIKE ? WHERE LOWER(login) LIKE ?
ORDER BY login ORDER BY login
@ -219,7 +219,7 @@ public final class SolanaUsersDAO {
e.setBlockchainName(rs.getString("blockchain_name")); e.setBlockchainName(rs.getString("blockchain_name"));
e.setSolanaKey(rs.getString("solana_key")); e.setSolanaKey(rs.getString("solana_key"));
e.setBlockchainKey(rs.getString("blockchain_key")); e.setBlockchainKey(rs.getString("blockchain_key"));
e.setDeviceKey(rs.getString("device_key")); e.setClientKey(rs.getString("client_key"));
return e; return e;
} }

View File

@ -7,7 +7,7 @@ import java.sql.*;
/** /**
* UserCreateDAO атомарное добавление пользователя: * UserCreateDAO атомарное добавление пользователя:
* - solana_users (login, blockchain_name, solana_key, blockchain_key, device_key) * - solana_users (login, blockchain_name, solana_key, blockchain_key, client_key)
* - blockchain_state (blockchain_name, login, blockchain_key, size_limit, ... last_block_number=-1 ...) * - blockchain_state (blockchain_name, login, blockchain_key, size_limit, ... last_block_number=-1 ...)
* *
* ВАЖНО: * ВАЖНО:
@ -39,7 +39,7 @@ public final class UserCreateDAO {
String blockchainName, String blockchainName,
String solanaKey, String solanaKey,
String blockchainKey, String blockchainKey,
String deviceKey, String clientKey,
long sizeLimit, long sizeLimit,
long nowMs long nowMs
) throws SQLException { ) throws SQLException {
@ -55,7 +55,7 @@ public final class UserCreateDAO {
u.setBlockchainName(blockchainName); u.setBlockchainName(blockchainName);
u.setSolanaKey(solanaKey); u.setSolanaKey(solanaKey);
u.setBlockchainKey(blockchainKey); u.setBlockchainKey(blockchainKey);
u.setDeviceKey(deviceKey); u.setClientKey(clientKey);
usersDao.insert(c, u); // если login занят (NOCASE) или blockchainName (unique) -> constraint usersDao.insert(c, u); // если login занят (NOCASE) или blockchainName (unique) -> constraint

View File

@ -43,14 +43,14 @@ public final class UserParamsDAO {
param, param,
time_ms, time_ms,
value, value,
device_key, client_key,
signature signature
) VALUES (?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(login, param) ON CONFLICT(login, param)
DO UPDATE SET DO UPDATE SET
time_ms = excluded.time_ms, time_ms = excluded.time_ms,
value = excluded.value, value = excluded.value,
device_key = excluded.device_key, client_key = excluded.client_key,
signature = excluded.signature signature = excluded.signature
WHERE users_params.time_ms < excluded.time_ms WHERE users_params.time_ms < excluded.time_ms
"""; """;
@ -61,7 +61,7 @@ public final class UserParamsDAO {
ps.setLong(3, e.getTimeMs()); ps.setLong(3, e.getTimeMs());
ps.setString(4, e.getValue()); ps.setString(4, e.getValue());
if (e.getDeviceKey() != null) ps.setString(5, e.getDeviceKey()); if (e.getClientKey() != null) ps.setString(5, e.getClientKey());
else ps.setNull(5, Types.VARCHAR); else ps.setNull(5, Types.VARCHAR);
if (e.getSignature() != null) ps.setString(6, e.getSignature()); if (e.getSignature() != null) ps.setString(6, e.getSignature());
@ -86,7 +86,7 @@ public final class UserParamsDAO {
param, param,
time_ms, time_ms,
value, value,
device_key, client_key,
signature signature
FROM users_params FROM users_params
WHERE login = ? COLLATE NOCASE AND param = ? WHERE login = ? COLLATE NOCASE AND param = ?
@ -117,7 +117,7 @@ public final class UserParamsDAO {
param, param,
time_ms, time_ms,
value, value,
device_key, client_key,
signature signature
FROM users_params FROM users_params
WHERE login = ? COLLATE NOCASE WHERE login = ? COLLATE NOCASE
@ -149,9 +149,9 @@ public final class UserParamsDAO {
e.setTimeMs(rs.getLong("time_ms")); e.setTimeMs(rs.getLong("time_ms"));
e.setValue(rs.getString("value")); e.setValue(rs.getString("value"));
String dk = rs.getString("device_key"); String dk = rs.getString("client_key");
if (rs.wasNull()) dk = null; if (rs.wasNull()) dk = null;
e.setDeviceKey(dk); e.setClientKey(dk);
String sig = rs.getString("signature"); String sig = rs.getString("signature");
if (rs.wasNull()) sig = null; if (rs.wasNull()) sig = null;

View File

@ -12,7 +12,7 @@ import java.util.Base64;
* - blockchain_name TEXT NOT NULL * - blockchain_name TEXT NOT NULL
* - solana_key TEXT NOT NULL * - solana_key TEXT NOT NULL
* - blockchain_key TEXT NOT NULL * - blockchain_key TEXT NOT NULL
* - device_key TEXT NOT NULL * - client_key TEXT NOT NULL
*/ */
public class SolanaUserEntry { public class SolanaUserEntry {
@ -27,7 +27,7 @@ public class SolanaUserEntry {
private String blockchainKey; private String blockchainKey;
/** Ключ устройства (публичный ключ устройства) */ /** Ключ устройства (публичный ключ устройства) */
private String deviceKey; private String clientKey;
public SolanaUserEntry() {} public SolanaUserEntry() {}
@ -35,12 +35,12 @@ public class SolanaUserEntry {
String blockchainName, String blockchainName,
String solanaKey, String solanaKey,
String blockchainKey, String blockchainKey,
String deviceKey) { String clientKey) {
this.login = login; this.login = login;
this.blockchainName = blockchainName; this.blockchainName = blockchainName;
this.solanaKey = solanaKey; this.solanaKey = solanaKey;
this.blockchainKey = blockchainKey; this.blockchainKey = blockchainKey;
this.deviceKey = deviceKey; this.clientKey = clientKey;
} }
public String getLogin() { return login; } public String getLogin() { return login; }
@ -55,13 +55,13 @@ public class SolanaUserEntry {
public String getBlockchainKey() { return blockchainKey; } public String getBlockchainKey() { return blockchainKey; }
public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; }
public String getDeviceKey() { return deviceKey; } public String getClientKey() { return clientKey; }
public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } public void setClientKey(String clientKey) { this.clientKey = clientKey; }
// оставляю этот метод как утилиту (иногда удобно), но он работает только для deviceKey: // оставляю этот метод как утилиту (иногда удобно), но он работает только для clientKey:
public byte[] getDeviceKeyByte() { public byte[] getClientKeyByte() {
if (deviceKey == null) return null; if (clientKey == null) return null;
String s = deviceKey.trim(); String s = clientKey.trim();
if (s.isEmpty()) return null; if (s.isEmpty()) return null;
try { try {

View File

@ -8,7 +8,7 @@ package shine.db.entities;
* - param TEXT NOT NULL * - param TEXT NOT NULL
* - time_ms INTEGER NOT NULL * - time_ms INTEGER NOT NULL
* - value TEXT NOT NULL * - value TEXT NOT NULL
* - device_key TEXT NULL * - client_key TEXT NULL
* - signature TEXT NULL * - signature TEXT NULL
*/ */
public class UserParamEntry { public class UserParamEntry {
@ -18,17 +18,17 @@ public class UserParamEntry {
private long timeMs; private long timeMs;
private String value; private String value;
private String deviceKey; private String clientKey;
private String signature; private String signature;
public UserParamEntry() {} public UserParamEntry() {}
public UserParamEntry(String login, String param, long timeMs, String value, String deviceKey, String signature) { public UserParamEntry(String login, String param, long timeMs, String value, String clientKey, String signature) {
this.login = login; this.login = login;
this.param = param; this.param = param;
this.timeMs = timeMs; this.timeMs = timeMs;
this.value = value; this.value = value;
this.deviceKey = deviceKey; this.clientKey = clientKey;
this.signature = signature; this.signature = signature;
} }
@ -44,8 +44,8 @@ public class UserParamEntry {
public String getValue() { return value; } public String getValue() { return value; }
public void setValue(String value) { this.value = value; } public void setValue(String value) { this.value = value; }
public String getDeviceKey() { return deviceKey; } public String getClientKey() { return clientKey; }
public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } public void setClientKey(String clientKey) { this.clientKey = clientKey; }
public String getSignature() { return signature; } public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; } public void setSignature(String signature) { this.signature = signature; }

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

View File

@ -10,7 +10,7 @@ import shine.db.entities.ActiveSessionEntry;
* *
* Важно (v2): * Важно (v2):
* - Авторизация всегда 2 шага: * - Авторизация всегда 2 шага:
* A) Создание новой сессии через deviceKey: * A) Создание новой сессии через clientKey:
* AuthChallenge(login) -> ctx.authNonce * AuthChallenge(login) -> ctx.authNonce
* CreateAuthSession(...) -> ctx.AUTH_STATUS_USER + ctx.activeSession * CreateAuthSession(...) -> ctx.AUTH_STATUS_USER + ctx.activeSession
* *
@ -39,7 +39,7 @@ public class ConnectionContext {
/** /**
* Одноразовый nonce, выданный на шаге 1 (AuthChallenge), * Одноразовый nonce, выданный на шаге 1 (AuthChallenge),
* используется на шаге CreateAuthSession для проверки подписи deviceKey. * используется на шаге CreateAuthSession для проверки подписи clientKey.
*/ */
private String authNonce; private String authNonce;

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

View File

@ -1,140 +0,0 @@
package server.logic.ws_protocol.JSON.entyties;
/**
* Базовый класс для всех событий (event).
* Общие поля: op и payload.
*.
* Формат JSON (event):
* {
* "op": "...",
* "payload": { ... }
* }
*/
public abstract class Net_Event {
/** Имя операции / события (op). */
private String op;
/**
* Произвольные данные.
* В JSON это поле "payload".
*/
private Object payload;
// --- getters / setters ---
public String getOp() {
return op;
}
public void setOp(String op) {
this.op = op;
}
public Object getPayload() {
return payload;
}
public void setPayload(Object payload) {
this.payload = payload;
}
}
package server.logic.ws_protocol.JSON.entyties;
/**
* Ответ с ошибкой (любой отказ).
*.
* В payload будет:
* {
* "code": "...",
* "message": "..."
* }
*/
public class Net_Exception_Response extends Net_Response {
private String code;
private String message;
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
package server.logic.ws_protocol.JSON.entyties;
/**
* Базовый класс для всех запросов (client → server).
*.
* Наследуется от NetEvent и добавляет requestId.
*.
* Формат JSON (request):
* {
* "op": "...",
* "requestId": "...",
* "payload": { ... }
* }
*/
public abstract class Net_Request extends Net_Event {
/** Идентификатор запроса, чтобы связать запрос и ответ. */
private String requestId;
// --- getters / setters ---
public String getRequestId() {
return requestId;
}
public void setRequestId(String requestId) {
this.requestId = requestId;
}
}
package server.logic.ws_protocol.JSON.entyties;
/**
* Базовый класс для всех ответов (server → client).
*.
* Наследуется от NetRequest и добавляет status.
*.
* Формат JSON (response):
* {
* "op": "...",
* "requestId": "...",
* "status": 200,
* "payload": { ... } // и для успеха, и для ошибки
* }
*/
public abstract class Net_Response extends Net_Request {
/** Статус результата (200 — успех, любое другое значение — ошибка). */
private int status;
// --- getters / setters ---
public int getStatus() {
return status;
}
public void setStatus(int status) {
this.status = status;
}
public boolean isOk() {
return status == 200;
}
}

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

View File

@ -20,9 +20,9 @@ import java.security.SecureRandom;
* AuthChallenge (v2) шаг 1 создания новой сессии. * AuthChallenge (v2) шаг 1 создания новой сессии.
* *
* Логика авторизации (v2): * Логика авторизации (v2):
* - Создание новой сессии возможно ТОЛЬКО через deviceKey пользователя. * - Создание новой сессии возможно ТОЛЬКО через clientKey пользователя.
* - Этот handler выдаёт одноразовый authNonce, который клиент использует во втором шаге: * - Этот handler выдаёт одноразовый authNonce, который клиент использует во втором шаге:
* CreateAuthSession(..., signature(deviceKey, AUTH_CREATE_SESSION:...)) * CreateAuthSession(..., signature(clientKey, AUTH_CREATE_SESSION:...))
* *
* Что делает: * Что делает:
* 1) Проверяет login. * 1) Проверяет login.

View File

@ -30,7 +30,7 @@ import java.security.SecureRandom;
import java.sql.SQLException; import java.sql.SQLException;
/** /**
* CreateAuthSession (v2) шаг 2 создания новой сессии (ТОЛЬКО deviceKey). * CreateAuthSession (v2) шаг 2 создания новой сессии (ТОЛЬКО clientKey).
* *
* Логика авторизации (v2): * Логика авторизации (v2):
* - Создание сессии: AuthChallenge(login) -> authNonce -> CreateAuthSession(...) * - Создание сессии: AuthChallenge(login) -> authNonce -> CreateAuthSession(...)
@ -38,7 +38,7 @@ import java.sql.SQLException;
* отправляет на сервер sessionKey целиком одной строкой. * отправляет на сервер sessionKey целиком одной строкой.
* - Сервер сохраняет sessionKey в active_sessions.session_key как есть. * - Сервер сохраняет sessionKey в active_sessions.session_key как есть.
* *
* Подпись deviceKey (Ed25519) проверяется над строкой (UTF-8): * Подпись clientKey (Ed25519) проверяется над строкой (UTF-8):
* AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} * AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
* *
* На выходе: * На выходе:
@ -226,15 +226,15 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
} }
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getClientPlatform()); String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getClientPlatform());
String deviceKeyFromDb = user.getDeviceKey(); String clientKeyFromDb = user.getClientKey();
if (deviceKeyFromDb == null || deviceKeyFromDb.isBlank()) { if (clientKeyFromDb == null || clientKeyFromDb.isBlank()) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
req, req,
WireCodes.Status.BAD_REQUEST, WireCodes.Status.BAD_REQUEST,
"NO_DEVICE_KEY", "NO_DEVICE_KEY",
"Отсутствует deviceKey у пользователя" "Отсутствует clientKey у пользователя"
); );
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: no deviceKey"); closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: no clientKey");
return err; return err;
} }
@ -261,28 +261,28 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
return err; return err;
} }
String deviceKeyFromReq = req.getDeviceKey(); String clientKeyFromReq = req.getClientKey();
if (deviceKeyFromReq == null || deviceKeyFromReq.isBlank()) { if (clientKeyFromReq == null || clientKeyFromReq.isBlank()) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
req, req,
WireCodes.Status.BAD_REQUEST, WireCodes.Status.BAD_REQUEST,
"EMPTY_DEVICE_KEY", "EMPTY_DEVICE_KEY",
"Пустой deviceKey" "Пустой clientKey"
); );
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: empty deviceKey"); closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: empty clientKey");
return err; return err;
} }
deviceKeyFromReq = deviceKeyFromReq.trim(); clientKeyFromReq = clientKeyFromReq.trim();
// TODO: для ротации device_key стоит дополнительно сверять актуальное значение через Solana. // TODO: для ротации client_key стоит дополнительно сверять актуальное значение через Solana.
if (!deviceKeyFromReq.equals(deviceKeyFromDb)) { if (!clientKeyFromReq.equals(clientKeyFromDb)) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
req, req,
WireCodes.Status.UNVERIFIED, WireCodes.Status.UNVERIFIED,
"DEVICE_KEY_NOT_ACTUAL", "DEVICE_KEY_NOT_ACTUAL",
"device_key не соответствует актуальной версии" "client_key не соответствует актуальной версии"
); );
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: device key mismatch"); closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: client key mismatch");
return err; return err;
} }
@ -294,7 +294,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
storagePwd, storagePwd,
authNonce, authNonce,
timeMs, timeMs,
deviceKeyFromDb, clientKeyFromDb,
signatureB64 signatureB64
); );
} catch (UnsupportedOperationException ex) { } catch (UnsupportedOperationException ex) {
@ -302,9 +302,9 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
req, req,
422, 422,
"UNSUPPORTED_KEY_ALGORITHM", "UNSUPPORTED_KEY_ALGORITHM",
"deviceKey algorithm is not supported" "clientKey algorithm is not supported"
); );
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: unsupported device key algorithm"); closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: unsupported client key algorithm");
return err; return err;
} catch (IllegalArgumentException ex) { } catch (IllegalArgumentException ex) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
@ -440,11 +440,11 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
String storagePwd, String storagePwd,
String authNonce, String authNonce,
long timeMs, long timeMs,
String deviceKey, String clientKey,
String signatureB64 String signatureB64
) throws IllegalArgumentException { ) throws IllegalArgumentException {
byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(deviceKey, "deviceKey"); byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(clientKey, "clientKey");
byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64");
String preimageStr = "AUTH_CREATE_SESSION:" String preimageStr = "AUTH_CREATE_SESSION:"

View File

@ -48,9 +48,9 @@ public final class SolanaUserPdaImportService {
boolean inserted = UserCreateDAO.getInstance().insertUserWithBlockchain( boolean inserted = UserCreateDAO.getInstance().insertUserWithBlockchain(
parsed.login, parsed.login,
parsed.blockchainName, parsed.blockchainName,
parsed.deviceKeyB64, // в текущей модели solanaKey = deviceKey parsed.clientKeyB64, // в текущей модели solanaKey = clientKey
parsed.blockchainKeyB64, parsed.blockchainKeyB64,
parsed.deviceKeyB64, parsed.clientKeyB64,
sizeLimit, sizeLimit,
now now
); );
@ -158,7 +158,7 @@ public final class SolanaUserPdaImportService {
int blocksCount = u8(raw, c++); int blocksCount = u8(raw, c++);
String blockchainName = null; String blockchainName = null;
byte[] blockchainKey32 = null; byte[] blockchainKey32 = null;
byte[] deviceKey32 = null; byte[] clientKey32 = null;
long paidLimitBytes = 0L; long paidLimitBytes = 0L;
List<ParsedSessionRecord> sessions = new ArrayList<>(); List<ParsedSessionRecord> sessions = new ArrayList<>();
@ -167,10 +167,12 @@ public final class SolanaUserPdaImportService {
int blockVer = u8(raw, c++); int blockVer = u8(raw, c++);
if (blockVer != 0) return null; if (blockVer != 0) return null;
if (blockType == 1) { if (blockType == 0) {
c += 32; // recovery_key
} else if (blockType == 1) {
c += 32; c += 32;
} else if (blockType == 2) { } else if (blockType == 2) {
deviceKey32 = slice(raw, c, 32); clientKey32 = slice(raw, c, 32);
c += 32; c += 32;
} else if (blockType == 3) { } else if (blockType == 3) {
int count = u8(raw, c++); int count = u8(raw, c++);
@ -245,12 +247,12 @@ public final class SolanaUserPdaImportService {
if (c > recordLen) return null; if (c > recordLen) return null;
} }
if (blockchainName == null || blockchainKey32 == null || deviceKey32 == null) return null; if (blockchainName == null || blockchainKey32 == null || clientKey32 == null) return null;
return new ParsedSolanaUser( return new ParsedSolanaUser(
login, login,
blockchainName, blockchainName,
Base64.getEncoder().encodeToString(blockchainKey32), Base64.getEncoder().encodeToString(blockchainKey32),
Base64.getEncoder().encodeToString(deviceKey32), Base64.getEncoder().encodeToString(clientKey32),
paidLimitBytes, paidLimitBytes,
sessions sessions
); );
@ -318,7 +320,7 @@ public final class SolanaUserPdaImportService {
String login, String login,
String blockchainName, String blockchainName,
String blockchainKeyB64, String blockchainKeyB64,
String deviceKeyB64, String clientKeyB64,
long paidLimitBytes, long paidLimitBytes,
List<ParsedSessionRecord> sessions List<ParsedSessionRecord> sessions
) {} ) {}

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

View File

@ -35,7 +35,7 @@
1. Добавление пользователя (AddUser) 1. Добавление пользователя (AddUser)
Назначение: создать локальную запись пользователя с двумя ключами — solanaKey и deviceKey. Назначение: создать локальную запись пользователя с двумя ключами — solanaKey и clientKey.
📤 Запрос клиента 📤 Запрос клиента
{ {
@ -46,7 +46,7 @@
"loginId": 100212, "loginId": 100212,
"bchId": 4222, "bchId": 4222,
"solanaKey": "BASE64_LOGIN_KEY", "solanaKey": "BASE64_LOGIN_KEY",
"deviceKey": "BASE64_DEVICE_KEY", "clientKey": "BASE64_DEVICE_KEY",
"bchLimit": 1000000 "bchLimit": 1000000
} }
} }
@ -62,7 +62,7 @@ login TEXT NOT NULL,
loginId INTEGER PRIMARY KEY, loginId INTEGER PRIMARY KEY,
bchId INTEGER NOT NULL, bchId INTEGER NOT NULL,
solanaKey TEXT, solanaKey TEXT,
deviceKey TEXT, clientKey TEXT,
bchLimit INTEGER bchLimit INTEGER
); );
@ -118,7 +118,7 @@ timeMs — timestamp клиента (UTC).
sessionPwd — строка с шага 1. sessionPwd — строка с шага 1.
signatureB64 — Ed25519подпись preimage приватным ключом deviceKey. signatureB64 — Ed25519подпись preimage приватным ключом clientKey.
📤 Запрос клиента 📤 Запрос клиента
{ {
@ -141,7 +141,7 @@ signatureB64 — Ed25519подпись preimage приватным ключо
Восстанавливает preimage. Восстанавливает preimage.
Находит deviceKey пользователя. Находит clientKey пользователя.
Проверяет Ed25519-подпись. Проверяет Ed25519-подпись.

View File

@ -3,13 +3,13 @@ package server.logic.ws_protocol.JSON.handlers.auth.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request; import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** /**
* Шаг 2 (v2): создание новой сессии ТОЛЬКО через deviceKey. * Шаг 2 (v2): создание новой сессии ТОЛЬКО через clientKey.
* *
* Шаги: * Шаги:
* 1) AuthChallenge(login) -> authNonce * 1) AuthChallenge(login) -> authNonce
* 2) CreateAuthSession(login, sessionKey, storagePwd, timeMs, authNonce, deviceKey, signatureB64, clientInfo) * 2) CreateAuthSession(login, sessionKey, storagePwd, timeMs, authNonce, clientKey, signatureB64, clientInfo)
* *
* Подпись deviceKey делается над строкой (UTF-8): * Подпись clientKey делается над строкой (UTF-8):
* AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} * AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
* *
* Важно: * Важно:
@ -33,9 +33,9 @@ public class Net_CreateAuthSession_Request extends Net_Request {
private String authNonce; private String authNonce;
/** Публичный ключ устройства пользователя. */ /** Публичный ключ устройства пользователя. */
private String deviceKey; private String clientKey;
/** Подпись Ed25519(deviceKey) над строкой AUTH_CREATE_SESSION:... (base64). */ /** Подпись Ed25519(clientKey) над строкой AUTH_CREATE_SESSION:... (base64). */
private String signatureB64; private String signatureB64;
/** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */
@ -87,12 +87,12 @@ public class Net_CreateAuthSession_Request extends Net_Request {
this.authNonce = authNonce; this.authNonce = authNonce;
} }
public String getDeviceKey() { public String getClientKey() {
return deviceKey; return clientKey;
} }
public void setDeviceKey(String deviceKey) { public void setClientKey(String clientKey) {
this.deviceKey = deviceKey; this.clientKey = clientKey;
} }
public String getSignatureB64() { public String getSignatureB64() {

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

View File

@ -1,180 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.connections.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/**
* Запрос GetFriendsLists — получить два списка "друзей" по connections_state.
*
* {
* "op": "GetFriendsLists",
* "requestId": "req-100",
* "payload": {
* "login": "anya"
* }
* }
*
* Возвращает:
* - out_friends: кому login поставил FRIEND
* - in_friends: кто поставил FRIEND этому login
*
* ПРО ДОСТУП (на будущее):
* Сейчас (MVP) без ограничений. Позже можно ограничить видимость связей.
*/
public class Net_GetFriendsLists_Request extends Net_Request {
private String login;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
}
package server.logic.ws_protocol.JSON.handlers.connections.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
/**
* Ответ GetFriendsLists.
*
* {
* "op": "GetFriendsLists",
* "requestId": "req-100",
* "status": 200,
* "payload": {
* "login": "Anya", // канонический регистр из БД
* "out_friends": ["Bob", "Kate"], // кому login поставил FRIEND
* "in_friends": ["Alex", "Kate"] // кто поставил FRIEND login
* }
* }
*/
public class Net_GetFriendsLists_Response extends Net_Response {
private String login;
private List<String> out_friends = new ArrayList<>();
private List<String> in_friends = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public List<String> getOut_friends() { return out_friends; }
public void setOut_friends(List<String> out_friends) { this.out_friends = out_friends; }
public List<String> getIn_friends() { return in_friends; }
public void setIn_friends(List<String> in_friends) { this.in_friends = in_friends; }
}
package server.logic.ws_protocol.JSON.handlers.connections;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Request;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.MsgSubType;
import shine.db.SqliteDbController;
import shine.db.dao.ConnectionsStateDAO;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.List;
/**
* GetFriendsLists — получить 2 списка:
* - out_friends: кому login поставил FRIEND
* - in_friends: кто поставил FRIEND этому login
*
* ВАЖНО:
* - login в запросе может быть любым регистром
* - в ответе возвращаем канонический регистр (как в solana_users.login)
*
* ПРИМЕЧАНИЕ:
* Таблица пользователей тут названа "solana_users". Если у тебя иначе — поменяй SQL.
*/
public class Net_GetFriendsLists_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetFriendsLists_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetFriendsLists_Request req = (Net_GetFriendsLists_Request) baseRequest;
if (req.getLogin() == null || req.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS",
"Некорректные поля: login"
);
}
final String loginAnyCase = req.getLogin().trim();
try {
SqliteDbController db = SqliteDbController.getInstance();
ConnectionsStateDAO dao = ConnectionsStateDAO.getInstance();
try (Connection c = db.getConnection()) {
// 1) Канонизируем login через solana_users (NOCASE)
String canonicalLogin = findCanonicalLogin(c, loginAnyCase);
if (canonicalLogin == null) {
return NetExceptionResponseFactory.error(
req,
404,
"USER_NOT_FOUND",
"Пользователь не найден"
);
}
int relType = (int) MsgSubType.CONNECTION_FRIEND;
// 2) Два списка (логины канонические)
List<String> outFriends = dao.listOutgoingByRelTypeCanonical(c, canonicalLogin, relType);
List<String> inFriends = dao.listIncomingByRelTypeCanonical(c, canonicalLogin, relType);
Net_GetFriendsLists_Response resp = new Net_GetFriendsLists_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(canonicalLogin);
resp.setOut_friends(outFriends);
resp.setIn_friends(inFriends);
return resp;
}
} catch (Exception e) {
log.error("❌ Internal error GetFriendsLists", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.INTERNAL_ERROR,
"INTERNAL_ERROR",
"Внутренняя ошибка сервера"
);
}
}
private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception {
String sql = """
SELECT login
FROM solana_users
WHERE login = ? COLLATE NOCASE
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, loginAnyCase);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return rs.getString("login");
}
}
}
}

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

View File

@ -62,7 +62,7 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
resp.setBlockchainName(u.getBlockchainName()); resp.setBlockchainName(u.getBlockchainName());
resp.setSolanaKey(u.getSolanaKey()); resp.setSolanaKey(u.getSolanaKey());
resp.setBlockchainKey(u.getBlockchainKey()); resp.setBlockchainKey(u.getBlockchainKey());
resp.setDeviceKey(u.getDeviceKey()); resp.setClientKey(u.getClientKey());
// Возвращаем актуальный курсор блокчейна и, если запись состояния потеряна, // Возвращаем актуальный курсор блокчейна и, если запись состояния потеряна,
// автоматически восстанавливаем её для существующего пользователя. // автоматически восстанавливаем её для существующего пользователя.

View File

@ -1,240 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/**
* Запрос AddUser — временная/тестовая регистрация локального пользователя.
*
* Клиент отправляет:
*
* {
* "op": "AddUser",
* "requestId": "test-add-1",
* "payload": {
* "login": "anya",
* "blockchainName": "anya-001",
* "solanaKey": "base64-ed25519-public-key-login",
* "blockchainKey": "base64-ed25519-public-key-blockchain",
* "deviceKey": "base64-ed25519-public-key-device",
* "bchLimit": 1000000
* }
* }
*
* Все поля лежат внутри payload.
*/
public class Net_AddUser_Request extends Net_Request {
private String login;
private String blockchainName;
/** Ключ пользователя Solana (публичный ключ логина) */
private String solanaKey;
/** Ключ блокчейна (публичный ключ блокчейна) */
private String blockchainKey;
/** Ключ устройства (публичный ключ устройства) */
private String deviceKey;
private Integer bchLimit;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getBlockchainName() { return blockchainName; }
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
public String getSolanaKey() { return solanaKey; }
public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; }
public String getBlockchainKey() { return blockchainKey; }
public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; }
public String getDeviceKey() { return deviceKey; }
public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; }
public Integer getBchLimit() { return bchLimit; }
public void setBchLimit(Integer bchLimit) { this.bchLimit = bchLimit; }
}
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
/**
* Успешный ответ на AddUser.
*
* Сейчас дополнительных полей нет — достаточно status=200.
*
* Пример:
* {
* "op": "AddUser",
* "requestId": "test-add-1",
* "status": 200,
* "payload": { }
* }
*/
public class Net_AddUser_Response extends Net_Response {
// При необходимости сюда можно добавить, например, флаг created/updated и т.п.
}
package server.logic.ws_protocol.JSON.handlers.tempToTest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.SqliteDbController;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.SolanaUserEntry;
import utils.blockchain.BlockchainNameUtil;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Base64;
public class Net_AddUser_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_AddUser_Handler.class);
/** TEST ONLY */
private static final int TEST_BCH_LIMIT = 1_000_000;
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_AddUser_Request req = (Net_AddUser_Request) baseRequest;
if (req.getLogin() == null || req.getLogin().isBlank()
|| req.getBlockchainName() == null || req.getBlockchainName().isBlank()
|| req.getSolanaKey() == null || req.getSolanaKey().isBlank()
|| req.getBlockchainKey() == null || req.getBlockchainKey().isBlank()
|| req.getDeviceKey() == null || req.getDeviceKey().isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS",
"Некорректные поля: login/blockchainName/solanaKey/blockchainKey/deviceKey"
);
}
// blockchainName должен быть вида: <login>-NNN
if (!BlockchainNameUtil.isBlockchainNameMatchesLogin(req.getBlockchainName(), req.getLogin())) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_BLOCKCHAIN_NAME",
"blockchainName должен быть вида <login>-NNN (пример: anya-001)"
);
}
int limit = (req.getBchLimit() == null || req.getBchLimit() <= 0)
? TEST_BCH_LIMIT
: req.getBchLimit();
try {
byte[] blockchainKey32 = Base64.getDecoder().decode(req.getBlockchainKey());
if (blockchainKey32.length != 32) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_BLOCKCHAIN_KEY",
"blockchainKey должен быть Base64(32 bytes)"
);
}
SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance();
BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
SqliteDbController db = SqliteDbController.getInstance();
try (Connection c = db.getConnection()) {
c.setAutoCommit(false);
// 1. Проверяем, что пользователя нет
if (usersDAO.getByLogin(req.getLogin()) != null) {
return NetExceptionResponseFactory.error(
req,
409,
"USER_ALREADY_EXISTS",
"Пользователь с таким login уже существует"
);
}
// 2. Проверяем, что blockchain_state ещё нет
if (stateDAO.getByBlockchainName(req.getBlockchainName()) != null) {
return NetExceptionResponseFactory.error(
req,
409,
"BLOCKCHAIN_ALREADY_EXISTS",
"blockchain_state уже существует"
);
}
// 3. Создаём пользователя (solanaKey + deviceKey)
SolanaUserEntry user = new SolanaUserEntry(
req.getLogin(),
req.getSolanaKey(),
req.getDeviceKey()
);
usersDAO.insert(c, user);
// 4. Создаём INITIAL blockchain_state (blockchainKey)
BlockchainStateEntry st = new BlockchainStateEntry();
st.setBlockchainName(req.getBlockchainName());
st.setLogin(req.getLogin());
st.setBlockchainKey(req.getBlockchainKey()); // Base64(32)
st.setLastBlockNumber(-1);
st.setLastBlockHash(new byte[32]);
st.setFileSizeBytes(0);
st.setSizeLimit(limit);
st.setUpdatedAtMs(System.currentTimeMillis());
stateDAO.upsert(c, st);
c.commit();
}
Net_AddUser_Response resp = new Net_AddUser_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
log.info("✅ AddUser ok: login={}, blockchainName={}, limit={}",
req.getLogin(), req.getBlockchainName(), limit);
return resp;
} catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_KEY_FORMAT",
e.getMessage()
);
} catch (SQLException e) {
log.error("❌ DB error AddUser", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.SERVER_DATA_ERROR,
"DB_ERROR",
"Ошибка БД"
);
} catch (Exception e) {
log.error("❌ Internal error AddUser", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.INTERNAL_ERROR,
"INTERNAL_ERROR",
"Внутренняя ошибка сервера"
);
}
}
}

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

View File

@ -15,7 +15,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
* "blockchainName": "anya-001", * "blockchainName": "anya-001",
* "solanaKey": "base64-ed25519-public-key-login", * "solanaKey": "base64-ed25519-public-key-login",
* "blockchainKey": "base64-ed25519-public-key-blockchain", * "blockchainKey": "base64-ed25519-public-key-blockchain",
* "deviceKey": "base64-ed25519-public-key-device", * "clientKey": "base64-ed25519-public-key-device",
* "bchLimit": 1000000 * "bchLimit": 1000000
* } * }
* } * }
@ -34,7 +34,7 @@ public class Net_AddUser_Request extends Net_Request {
private String blockchainKey; private String blockchainKey;
/** Ключ устройства (публичный ключ устройства) */ /** Ключ устройства (публичный ключ устройства) */
private String deviceKey; private String clientKey;
private Integer bchLimit; private Integer bchLimit;
@ -50,8 +50,8 @@ public class Net_AddUser_Request extends Net_Request {
public String getBlockchainKey() { return blockchainKey; } public String getBlockchainKey() { return blockchainKey; }
public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; }
public String getDeviceKey() { return deviceKey; } public String getClientKey() { return clientKey; }
public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } public void setClientKey(String clientKey) { this.clientKey = clientKey; }
public Integer getBchLimit() { return bchLimit; } public Integer getBchLimit() { return bchLimit; }
public void setBchLimit(Integer bchLimit) { this.bchLimit = bchLimit; } public void setBchLimit(Integer bchLimit) { this.bchLimit = bchLimit; }

View File

@ -26,7 +26,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
* "blockchainName": "anya-001", * "blockchainName": "anya-001",
* "solanaKey": "...", * "solanaKey": "...",
* "blockchainKey": "...", * "blockchainKey": "...",
* "deviceKey": "..." * "clientKey": "..."
* } * }
* } * }
*/ */
@ -38,7 +38,7 @@ public class Net_GetUser_Response extends Net_Response {
private String blockchainName; private String blockchainName;
private String solanaKey; private String solanaKey;
private String blockchainKey; private String blockchainKey;
private String deviceKey; private String clientKey;
private Integer serverLastGlobalNumber; private Integer serverLastGlobalNumber;
private String serverLastGlobalHash; private String serverLastGlobalHash;
private Long serverBlockchainSizeBytes; private Long serverBlockchainSizeBytes;
@ -59,8 +59,8 @@ public class Net_GetUser_Response extends Net_Response {
public String getBlockchainKey() { return blockchainKey; } public String getBlockchainKey() { return blockchainKey; }
public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; }
public String getDeviceKey() { return deviceKey; } public String getClientKey() { return clientKey; }
public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; } public void setClientKey(String clientKey) { this.clientKey = clientKey; }
public Integer getServerLastGlobalNumber() { return serverLastGlobalNumber; } public Integer getServerLastGlobalNumber() { return serverLastGlobalNumber; }
public void setServerLastGlobalNumber(Integer serverLastGlobalNumber) { this.serverLastGlobalNumber = serverLastGlobalNumber; } public void setServerLastGlobalNumber(Integer serverLastGlobalNumber) { this.serverLastGlobalNumber = serverLastGlobalNumber; }

View File

@ -71,7 +71,7 @@ public class Net_GetUserParam_Handler implements JsonMessageHandler {
resp.setParam(e.getParam()); resp.setParam(e.getParam());
resp.setTime_ms(e.getTimeMs()); resp.setTime_ms(e.getTimeMs());
resp.setValue(e.getValue()); resp.setValue(e.getValue());
resp.setDevice_key(e.getDeviceKey()); resp.setClient_key(e.getClientKey());
resp.setSignature(e.getSignature()); resp.setSignature(e.getSignature());
return resp; return resp;

View File

@ -73,7 +73,7 @@ public class Net_ListUserParams_Handler implements JsonMessageHandler {
it.setParam(e.getParam()); it.setParam(e.getParam());
it.setTime_ms(e.getTimeMs()); it.setTime_ms(e.getTimeMs());
it.setValue(e.getValue()); it.setValue(e.getValue());
it.setDevice_key(e.getDeviceKey()); it.setClient_key(e.getClientKey());
it.setSignature(e.getSignature()); it.setSignature(e.getSignature());
items.add(it); items.add(it);
} }

View File

@ -28,8 +28,8 @@ import java.sql.SQLException;
* *
* Делает (MVP, без "сессий"): * Делает (MVP, без "сессий"):
* 1) Проверка входных полей. * 1) Проверка входных полей.
* 2) Проверка подписи Ed25519 по device_key. * 2) Проверка подписи Ed25519 по client_key.
* 3) Проверка, что пользователь существует и что device_key принадлежит этому login. * 3) Проверка, что пользователь существует и что client_key принадлежит этому login.
* 4) Атомарная запись в БД "только если time_ms новее" (UPSERT + WHERE). * 4) Атомарная запись в БД "только если time_ms новее" (UPSERT + WHERE).
* *
* ВАЖНО: * ВАЖНО:
@ -50,14 +50,14 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler {
|| req.getParam() == null || req.getParam().isBlank() || req.getParam() == null || req.getParam().isBlank()
|| req.getTime_ms() == null || req.getTime_ms() <= 0 || req.getTime_ms() == null || req.getTime_ms() <= 0
|| req.getValue() == null || req.getValue() == null
|| req.getDevice_key() == null || req.getDevice_key().isBlank() || req.getClient_key() == null || req.getClient_key().isBlank()
|| req.getSignature() == null || req.getSignature().isBlank()) { || req.getSignature() == null || req.getSignature().isBlank()) {
return NetExceptionResponseFactory.error( return NetExceptionResponseFactory.error(
req, req,
WireCodes.Status.BAD_REQUEST, WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS", "BAD_FIELDS",
"Некорректные поля: login/param/time_ms/value/device_key/signature" "Некорректные поля: login/param/time_ms/value/client_key/signature"
); );
} }
@ -65,7 +65,7 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler {
final String param = req.getParam().trim(); final String param = req.getParam().trim();
final long timeMs = req.getTime_ms(); final long timeMs = req.getTime_ms();
final String value = req.getValue(); final String value = req.getValue();
final String deviceKeyB64 = req.getDevice_key().trim(); final String clientKeyB64 = req.getClient_key().trim();
final String signatureB64 = req.getSignature().trim(); final String signatureB64 = req.getSignature().trim();
try { try {
@ -73,14 +73,14 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler {
byte[] pubKey32; byte[] pubKey32;
byte[] sig64; byte[] sig64;
try { try {
pubKey32 = Base64Ws.decodeLen(deviceKeyB64, 32, "device_key"); pubKey32 = Base64Ws.decodeLen(clientKeyB64, 32, "client_key");
sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature"); sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature");
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error( return NetExceptionResponseFactory.error(
req, req,
WireCodes.Status.BAD_REQUEST, WireCodes.Status.BAD_REQUEST,
"BAD_BASE64", "BAD_BASE64",
"device_key/signature должны быть Base64" "client_key/signature должны быть Base64"
); );
} }
@ -120,23 +120,23 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler {
); );
} }
// 2) device key must match the user's stored deviceKey // 2) client key must match the user's stored clientKey
String userDeviceKey = user.getDeviceKey(); String userClientKey = user.getClientKey();
if (userDeviceKey == null || userDeviceKey.isBlank()) { if (userClientKey == null || userClientKey.isBlank()) {
return NetExceptionResponseFactory.error( return NetExceptionResponseFactory.error(
req, req,
WireCodes.Status.SERVER_DATA_ERROR, WireCodes.Status.SERVER_DATA_ERROR,
"USER_DEVICE_KEY_EMPTY", "USER_DEVICE_KEY_EMPTY",
"У пользователя не задан deviceKey в БД" "У пользователя не задан clientKey в БД"
); );
} }
if (!userDeviceKey.trim().equals(deviceKeyB64)) { if (!userClientKey.trim().equals(clientKeyB64)) {
return NetExceptionResponseFactory.error( return NetExceptionResponseFactory.error(
req, req,
403, 403,
"DEVICE_KEY_MISMATCH", "DEVICE_KEY_MISMATCH",
"device_key не соответствует пользователю" "client_key не соответствует пользователю"
); );
} }
@ -146,7 +146,7 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler {
param, param,
timeMs, timeMs,
value, value,
deviceKeyB64, clientKeyB64,
signatureB64 signatureB64
); );

View File

@ -1,640 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/**
* Запрос GetUserParam — получить один параметр пользователя.
*
* {
* "op": "GetUserParam",
* "requestId": "req-1",
* "payload": {
* "login": "anya",
* "param": "feed:lastSeenGlobal"
* }
* }
*
* ПРО ДОСТУП (на будущее):
* ---------------------------------------------------------------------------------
* Сейчас (MVP) этот запрос не ограничивает просмотр параметров, т.к. проект в тестовом режиме.
* Позже, вероятно, потребуется ограничить: кто и какие параметры может читать (сессия/права).
* Но для MVP эти проверки не нужны.
* ---------------------------------------------------------------------------------
*/
public class Net_GetUserParam_Request extends Net_Request {
private String login;
private String param;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getParam() { return param; }
public void setParam(String param) { this.param = param; }
}
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
/**
* Ответ GetUserParam.
*
* Если найден:
* {
* "op": "GetUserParam",
* "requestId": "req-1",
* "status": 200,
* "payload": {
* "login": "anya",
* "param": "feed:lastSeenGlobal",
* "time_ms": 1736000000123,
* "value": "105",
* "device_key": "base64-32",
* "signature": "base64-64"
* }
* }
*
* Если не найден:
* status=404, payload пустой.
*/
public class Net_GetUserParam_Response extends Net_Response {
private String login;
private String param;
private Long time_ms;
private String value;
private String device_key;
private String signature;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getParam() { return param; }
public void setParam(String param) { this.param = param; }
public Long getTime_ms() { return time_ms; }
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
public String getDevice_key() { return device_key; }
public void setDevice_key(String device_key) { this.device_key = device_key; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
}
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/**
* Запрос ListUserParams — получить все сохранённые параметры пользователя.
*
* {
* "op": "ListUserParams",
* "requestId": "req-2",
* "payload": {
* "login": "anya"
* }
* }
*
* ПРО ДОСТУП (на будущее):
* ---------------------------------------------------------------------------------
* Сейчас (MVP) запрос не ограничивает просмотр параметров.
* В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры.
* Для MVP эти проверки не нужны.
* ---------------------------------------------------------------------------------
*/
public class Net_ListUserParams_Request extends Net_Request {
private String login;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
}
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
/**
* Ответ ListUserParams — список всех параметров пользователя.
*
* {
* "op": "ListUserParams",
* "requestId": "req-2",
* "status": 200,
* "payload": {
* "login": "anya",
* "params": [
* {
* "login": "anya",
* "param": "feed:lastSeenGlobal",
* "time_ms": 1736000000123,
* "value": "105",
* "device_key": "base64-32",
* "signature": "base64-64"
* },
* ...
* ]
* }
* }
*/
public class Net_ListUserParams_Response extends Net_Response {
private String login;
private List<Item> params = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public List<Item> getParams() { return params; }
public void setParams(List<Item> params) { this.params = params; }
public static class Item {
private String login;
private String param;
private Long time_ms;
private String value;
private String device_key;
private String signature;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getParam() { return param; }
public void setParam(String param) { this.param = param; }
public Long getTime_ms() { return time_ms; }
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
public String getDevice_key() { return device_key; }
public void setDevice_key(String device_key) { this.device_key = device_key; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
}
}
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/**
* Запрос UpsertUserParam — добавить/обновить сохранённый параметр пользователя.
*
* Клиент отправляет:
*
* {
* "op": "UpsertUserParam",
* "requestId": "req-123",
* "payload": {
* "login": "anya",
* "param": "feed:lastSeenGlobal",
* "time_ms": 1736000000123,
* "value": "105",
* "device_key": "base64-ed25519-public-key-32",
* "signature": "base64-ed25519-signature-64"
* }
* }
*
* Подпись считается от UTF-8 строки:
* USER_PARAMETER_PREFIX + login + param + time_ms + value
*/
public class Net_UpsertUserParam_Request extends Net_Request {
private String login;
private String param;
private Long time_ms;
private String value;
private String device_key;
private String signature;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getParam() { return param; }
public void setParam(String param) { this.param = param; }
public Long getTime_ms() { return time_ms; }
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
public String getDevice_key() { return device_key; }
public void setDevice_key(String device_key) { this.device_key = device_key; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
}
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
/**
* Ответ на UpsertUserParam.
*
* Успех:
* {
* "op": "UpsertUserParam",
* "requestId": "req-123",
* "status": 200,
* "payload": { }
* }
*/
public class Net_UpsertUserParam_Response extends Net_Response {
// MVP: без payload. При желании позже можно добавить created/updated.
}
package server.logic.ws_protocol.JSON.handlers.userParams;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Request;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.SqliteDbController;
import shine.db.dao.UserParamsDAO;
import shine.db.entities.UserParamEntry;
import java.sql.Connection;
/**
* GetUserParam — получить один параметр пользователя.
*
* ПРО ДОСТУП (на будущее):
* ---------------------------------------------------------------------------------
* Сейчас (MVP) запрос не ограничивает просмотр параметров.
* В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры.
* Для MVP эти проверки не нужны.
* ---------------------------------------------------------------------------------
*/
public class Net_GetUserParam_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetUserParam_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetUserParam_Request req = (Net_GetUserParam_Request) baseRequest;
if (req.getLogin() == null || req.getLogin().isBlank()
|| req.getParam() == null || req.getParam().isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS",
"Некорректные поля: login/param"
);
}
String login = req.getLogin().trim();
String param = req.getParam().trim();
try {
SqliteDbController db = SqliteDbController.getInstance();
UserParamsDAO dao = UserParamsDAO.getInstance();
try (Connection c = db.getConnection()) {
UserParamEntry e = dao.getByLoginAndParam(c, login, param);
if (e == null) {
Net_GetUserParam_Response resp = new Net_GetUserParam_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(404);
return resp;
}
Net_GetUserParam_Response resp = new Net_GetUserParam_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(e.getLogin());
resp.setParam(e.getParam());
resp.setTime_ms(e.getTimeMs());
resp.setValue(e.getValue());
resp.setDevice_key(e.getDeviceKey());
resp.setSignature(e.getSignature());
return resp;
}
} catch (Exception e) {
log.error("❌ Internal error GetUserParam", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.INTERNAL_ERROR,
"INTERNAL_ERROR",
"Внутренняя ошибка сервера"
);
}
}
}
package server.logic.ws_protocol.JSON.handlers.userParams;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Request;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.SqliteDbController;
import shine.db.dao.UserParamsDAO;
import shine.db.entities.UserParamEntry;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.List;
/**
* ListUserParams — получить все параметры пользователя.
*
* ПРО ДОСТУП (на будущее):
* ---------------------------------------------------------------------------------
* Сейчас (MVP) запрос не ограничивает просмотр параметров.
* В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры.
* Для MVP эти проверки не нужны.
* ---------------------------------------------------------------------------------
*/
public class Net_ListUserParams_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_ListUserParams_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_ListUserParams_Request req = (Net_ListUserParams_Request) baseRequest;
if (req.getLogin() == null || req.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS",
"Некорректные поля: login"
);
}
String login = req.getLogin().trim();
try {
SqliteDbController db = SqliteDbController.getInstance();
UserParamsDAO dao = UserParamsDAO.getInstance();
List<UserParamEntry> entries;
try (Connection c = db.getConnection()) {
entries = dao.getByLogin(c, login);
}
Net_ListUserParams_Response resp = new Net_ListUserParams_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(login);
List<Net_ListUserParams_Response.Item> items = new ArrayList<>();
for (UserParamEntry e : entries) {
Net_ListUserParams_Response.Item it = new Net_ListUserParams_Response.Item();
it.setLogin(e.getLogin());
it.setParam(e.getParam());
it.setTime_ms(e.getTimeMs());
it.setValue(e.getValue());
it.setDevice_key(e.getDeviceKey());
it.setSignature(e.getSignature());
items.add(it);
}
resp.setParams(items);
return resp;
} catch (Exception e) {
log.error("❌ Internal error ListUserParams", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.INTERNAL_ERROR,
"INTERNAL_ERROR",
"Внутренняя ошибка сервера"
);
}
}
}
package server.logic.ws_protocol.JSON.handlers.userParams;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Request;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.SqliteDbController;
import shine.db.dao.SolanaUsersDAO;
import shine.db.dao.UserParamsDAO;
import shine.db.entities.SolanaUserEntry;
import shine.db.entities.UserParamEntry;
import utils.config.ShineSignatureConstants;
import utils.crypto.Ed25519Util;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Base64;
/**
* Net_UpsertUserParam_Handler
*
* Делает (MVP, без "сессий"):
* 1) Проверка входных полей.
* 2) Проверка подписи Ed25519 по device_key.
* 3) Проверка, что пользователь существует и что device_key принадлежит этому login.
* 4) Атомарная запись в БД "только если time_ms новее" (UPSERT + WHERE).
*
* ВАЖНО:
* - НИКАКИХ ручных транзакций / BEGIN здесь нет.
* - autoCommit=true, каждый statement завершённый сам по себе.
* - Гонки не страшны: если за время проверок кто-то записал более новый time_ms,
* наш финальный UPSERT просто вернёт 0 обновлённых строк.
*/
public class Net_UpsertUserParam_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_UpsertUserParam_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_UpsertUserParam_Request req = (Net_UpsertUserParam_Request) baseRequest;
if (req.getLogin() == null || req.getLogin().isBlank()
|| req.getParam() == null || req.getParam().isBlank()
|| req.getTime_ms() == null || req.getTime_ms() <= 0
|| req.getValue() == null
|| req.getDevice_key() == null || req.getDevice_key().isBlank()
|| req.getSignature() == null || req.getSignature().isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS",
"Некорректные поля: login/param/time_ms/value/device_key/signature"
);
}
final String login = req.getLogin().trim();
final String param = req.getParam().trim();
final long timeMs = req.getTime_ms();
final String value = req.getValue();
final String deviceKeyB64 = req.getDevice_key().trim();
final String signatureB64 = req.getSignature().trim();
try {
// ---------------- Base64 decode ----------------
byte[] pubKey32;
byte[] sig64;
try {
pubKey32 = Base64.getDecoder().decode(deviceKeyB64);
sig64 = Base64.getDecoder().decode(signatureB64);
} catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_BASE64",
"device_key/signature должны быть Base64"
);
}
if (pubKey32.length != 32) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_DEVICE_KEY",
"device_key должен быть Base64(32 bytes)"
);
}
if (sig64.length != 64) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_SIGNATURE",
"signature должна быть Base64(64 bytes)"
);
}
// ---------------- Signature verify ----------------
String signText = ShineSignatureConstants.USER_PARAMETER_PREFIX
+ login
+ param
+ timeMs
+ value;
byte[] signBytes = signText.getBytes(StandardCharsets.UTF_8);
boolean sigOk = Ed25519Util.verify(signBytes, sig64, pubKey32);
if (!sigOk) {
return NetExceptionResponseFactory.error(
req,
403,
"SIGNATURE_INVALID",
"Подпись не прошла проверку"
);
}
// ---------------- DB checks + upsert ----------------
SqliteDbController db = SqliteDbController.getInstance();
SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance();
UserParamsDAO paramsDAO = UserParamsDAO.getInstance();
try (Connection c = db.getConnection()) {
// 1) user exists
SolanaUserEntry user = usersDAO.getByLogin(c, login);
if (user == null) {
return NetExceptionResponseFactory.error(
req,
404,
"USER_NOT_FOUND",
"Пользователь не найден"
);
}
// 2) device key must match the user's stored deviceKey
String userDeviceKey = user.getDeviceKey();
if (userDeviceKey == null || userDeviceKey.isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.SERVER_DATA_ERROR,
"USER_DEVICE_KEY_EMPTY",
"У пользователя не задан deviceKey в БД"
);
}
if (!userDeviceKey.trim().equals(deviceKeyB64)) {
return NetExceptionResponseFactory.error(
req,
403,
"DEVICE_KEY_MISMATCH",
"device_key не соответствует пользователю"
);
}
// 3) atomic upsert-if-newer
UserParamEntry e = new UserParamEntry(
login,
param,
timeMs,
value,
deviceKeyB64,
signatureB64
);
int changed = paramsDAO.upsertIfNewer(c, e);
Net_UpsertUserParam_Response resp = new Net_UpsertUserParam_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
if (changed == 1) {
log.info("✅ UpsertUserParam applied: login={}, param={}, time_ms={}", login, param, timeMs);
} else {
// 0 строк — значит в БД уже есть time_ms >= incoming
log.info(" UpsertUserParam ignored (not newer): login={}, param={}, time_ms={}", login, param, timeMs);
}
return resp;
}
} catch (SQLException e) {
log.error("❌ DB error UpsertUserParam", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.SERVER_DATA_ERROR,
"DB_ERROR",
"Ошибка БД"
);
} catch (Exception e) {
log.error("❌ Internal error UpsertUserParam", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.INTERNAL_ERROR,
"INTERNAL_ERROR",
"Внутренняя ошибка сервера"
);
}
}
}

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

View File

@ -15,7 +15,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
* "param": "feed:lastSeenGlobal", * "param": "feed:lastSeenGlobal",
* "time_ms": 1736000000123, * "time_ms": 1736000000123,
* "value": "105", * "value": "105",
* "device_key": "base64-32", * "client_key": "base64-32",
* "signature": "base64-64" * "signature": "base64-64"
* } * }
* } * }
@ -29,7 +29,7 @@ public class Net_GetUserParam_Response extends Net_Response {
private String param; private String param;
private Long time_ms; private Long time_ms;
private String value; private String value;
private String device_key; private String client_key;
private String signature; private String signature;
public String getLogin() { return login; } public String getLogin() { return login; }
@ -44,8 +44,8 @@ public class Net_GetUserParam_Response extends Net_Response {
public String getValue() { return value; } public String getValue() { return value; }
public void setValue(String value) { this.value = value; } public void setValue(String value) { this.value = value; }
public String getDevice_key() { return device_key; } public String getClient_key() { return client_key; }
public void setDevice_key(String device_key) { this.device_key = device_key; } public void setClient_key(String client_key) { this.client_key = client_key; }
public String getSignature() { return signature; } public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; } public void setSignature(String signature) { this.signature = signature; }

View File

@ -20,7 +20,7 @@ import java.util.List;
* "param": "feed:lastSeenGlobal", * "param": "feed:lastSeenGlobal",
* "time_ms": 1736000000123, * "time_ms": 1736000000123,
* "value": "105", * "value": "105",
* "device_key": "base64-32", * "client_key": "base64-32",
* "signature": "base64-64" * "signature": "base64-64"
* }, * },
* ... * ...
@ -44,7 +44,7 @@ public class Net_ListUserParams_Response extends Net_Response {
private String param; private String param;
private Long time_ms; private Long time_ms;
private String value; private String value;
private String device_key; private String client_key;
private String signature; private String signature;
public String getLogin() { return login; } public String getLogin() { return login; }
@ -59,8 +59,8 @@ public class Net_ListUserParams_Response extends Net_Response {
public String getValue() { return value; } public String getValue() { return value; }
public void setValue(String value) { this.value = value; } public void setValue(String value) { this.value = value; }
public String getDevice_key() { return device_key; } public String getClient_key() { return client_key; }
public void setDevice_key(String device_key) { this.device_key = device_key; } public void setClient_key(String client_key) { this.client_key = client_key; }
public String getSignature() { return signature; } public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; } public void setSignature(String signature) { this.signature = signature; }

View File

@ -15,7 +15,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
* "param": "feed:lastSeenGlobal", * "param": "feed:lastSeenGlobal",
* "time_ms": 1736000000123, * "time_ms": 1736000000123,
* "value": "105", * "value": "105",
* "device_key": "base64-ed25519-public-key-32", * "client_key": "base64-ed25519-public-key-32",
* "signature": "base64-ed25519-signature-64" * "signature": "base64-ed25519-signature-64"
* } * }
* } * }
@ -30,7 +30,7 @@ public class Net_UpsertUserParam_Request extends Net_Request {
private Long time_ms; private Long time_ms;
private String value; private String value;
private String device_key; private String client_key;
private String signature; private String signature;
public String getLogin() { return login; } public String getLogin() { return login; }
@ -45,8 +45,8 @@ public class Net_UpsertUserParam_Request extends Net_Request {
public String getValue() { return value; } public String getValue() { return value; }
public void setValue(String value) { this.value = value; } public void setValue(String value) { this.value = value; }
public String getDevice_key() { return device_key; } public String getClient_key() { return client_key; }
public void setDevice_key(String device_key) { this.device_key = device_key; } public void setClient_key(String client_key) { this.client_key = client_key; }
public String getSignature() { return signature; } public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; } public void setSignature(String signature) { this.signature = signature; }

View File

@ -62,9 +62,9 @@ public class Net_SendDirectMessage_Handler implements JsonMessageHandler {
byte[] publicKey32; byte[] publicKey32;
try { try {
publicKey32 = Ed25519Util.keyFromBase64(fromUser.getDeviceKey()); publicKey32 = Ed25519Util.keyFromBase64(fromUser.getClientKey());
} catch (Exception e) { } catch (Exception e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_DEVICE_KEY", "Некорректный deviceKey отправителя"); return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_DEVICE_KEY", "Некорректный clientKey отправителя");
} }
if (!Ed25519Util.verify(packet.signedBody, packet.signature64, publicKey32)) { if (!Ed25519Util.verify(packet.signedBody, packet.signature64, publicKey32)) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_SIGNATURE", "Подпись не прошла проверку"); return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_SIGNATURE", "Подпись не прошла проверку");

View File

@ -44,7 +44,7 @@ final class SignedMessagesCore {
if (from == null || to == null) { if (from == null || to == null) {
throw new IllegalArgumentException("USER_NOT_FOUND"); throw new IllegalArgumentException("USER_NOT_FOUND");
} }
byte[] pubKey32 = Ed25519Util.keyFromBase64(from.getDeviceKey()); byte[] pubKey32 = Ed25519Util.keyFromBase64(from.getClientKey());
if (!Ed25519Util.verify(block.signedBody, block.signature64, pubKey32)) { if (!Ed25519Util.verify(block.signedBody, block.signature64, pubKey32)) {
throw new IllegalArgumentException("BAD_SIGNATURE"); throw new IllegalArgumentException("BAD_SIGNATURE");
} }

View File

@ -31,7 +31,7 @@
// } // }
// //
// /** // /**
// * Проверка подписи CreateAuthSession(v2) по deviceKey пользователя. // * Проверка подписи CreateAuthSession(v2) по clientKey пользователя.
// * Подпись проверяется над preimageCreateAuthSession(...). // * Подпись проверяется над preimageCreateAuthSession(...).
// */ // */
// public static boolean verifyCreateAuthSessionSignature( // public static boolean verifyCreateAuthSessionSignature(
@ -42,8 +42,8 @@
// String signatureB64 // String signatureB64
// ) throws IllegalArgumentException { // ) throws IllegalArgumentException {
// //
// // user.getDeviceKey() base64 публичного ключа (32 байта) // // user.getClientKey() base64 публичного ключа (32 байта)
// byte[] publicKey32 = decodeBase64Any(user.getDeviceKey()); // byte[] publicKey32 = decodeBase64Any(user.getClientKey());
// byte[] signature64 = decodeBase64Any(signatureB64); // byte[] signature64 = decodeBase64Any(signatureB64);
// //
// byte[] preimage = preimageCreateAuthSession(login, timeMs, authNonce); // byte[] preimage = preimageCreateAuthSession(login, timeMs, authNonce);

View File

@ -1,552 +0,0 @@
package server.logic;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.binary.handlers.*;
import server.logic.ws_protocol.WireCodes;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Map;
/**
* Обработчик входящих сообщение на сервер.
* По коду сообщения (первые 4 байта сообщения) находи нужный хэндлер и передаёт в него сообщение
* Получает и возвращает ответ от хэндлера
*/
public final class InboundMessageProcessor {
private static final Logger log = LoggerFactory.getLogger(InboundMessageProcessor.class);
private static final Map<Integer, MessageHandler> HANDLERS = Map.of(
// WireCodes.Op.PING, new PingHandler()
// WireCodes.Op.ADD_BLOCK, new AddBlockHandler(),
// WireCodes.Op.GET_BLOCKCHAIN,new GetBlockchainHandler()
// WireCodes.Op.SEARCH_USERS, new SearchUsersHandler(),
// WireCodes.Op.GET_LAST_BLOCK_INFO,new GetLastBlockInfoHandler()
);
private InboundMessageProcessor() {}
public static byte[] process(byte[] msg) {
if (msg == null || msg.length < 4)
return intTo4Bytes(WireCodes.Status.BAD_REQUEST);
int op = first4ToInt(msg);
MessageHandler h = HANDLERS.get(op);
if (h == null) {
log.warn("Неизвестная операция: {}", op);
return intTo4Bytes(WireCodes.Status.BAD_REQUEST);
}
try {
return h.handle(msg);
} catch (Exception e) {
log.error("Ошибка при обработке операции {}", op, e);
return intTo4Bytes(WireCodes.Status.INTERNAL_ERROR);
}
}
private static int first4ToInt(byte[] msg) {
return ByteBuffer.wrap(msg, 0, 4)
.order(ByteOrder.BIG_ENDIAN)
.getInt();
}
public static byte[] intTo4Bytes(int code) {
return ByteBuffer.allocate(4)
.order(ByteOrder.BIG_ENDIAN)
.putInt(code)
.array();
}
}
package server.logic.ws_protocol.binary.handlers;
/**
* Общий интерфейс для всех обработчиков входящих сообщений.
*/
public interface MessageHandler {
/**
* Обработать входящее сообщение и вернуть бинарный ответ.
*/
byte[] handle(byte[] msg);
}
package server.ws;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.dao.BlockchainStateDAO;
import shine.db.entities.BlockchainStateEntry;
import utils.files.FileStoreUtil;
import shine.log.BlockchainAdminNotifier;
import java.io.IOException;
import java.nio.file.*;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
/**
* ===============================================================
* BlockchainTmpRecoveryOnStartup — восстановление консистентности
* blockchain файлов при старте сервера.
*
* Сценарий проблемы:
* - при добавлении блока сначала пишется <name>.tmp_bch
* - потом коммитится БД (state.fileSizeBytes)
* - потом tmp переименовывается поверх <name>.bch (атомарно, если возможно)
*
* Если сервер упал в середине, может остаться tmp:
* - tmp есть, а основной .bch остался старым
* - tmp есть, а основной .bch уже удалили/заменить не успели
* - tmp есть, а БД успела/не успела обновиться
*
* Этот класс при старте:
* - ищет все *.tmp_bch в data/
* - сравнивает размеры:
* - tmp
* - main (если есть)
* - state.fileSizeBytes (если есть)
*
* Правила:
*
* A) state есть:
* - если stateSize == mainSize => tmp удаляем
* - если stateSize == tmpSize => tmp ставим на место main (atomicReplaceBlockchainFile)
* - иначе => КРИТИЧЕСКАЯ ОШИБКА: сервер останавливаем + уведомление администратору
*
* B) state НЕТ:
* - если main НЕТ и tmp ЕСТЬ => tmp удаляем (мусор после падения/неуспешной транзакции)
* - если main ЕСТЬ и tmp ЕСТЬ => КРИТИЧЕСКАЯ ОШИБКА: уведомление администратору + стоп сервера
*
* Логирование:
* - обо всех восстановленных/удалённых tmp пишем в лог
* - если tmp-файлов нет — тоже пишем в лог
* ===============================================================
*/
public final class BlockchainTmpRecoveryOnStartup {
private static final Logger log = LoggerFactory.getLogger(BlockchainTmpRecoveryOnStartup.class);
private BlockchainTmpRecoveryOnStartup() {}
/**
* Запуск восстановления.
* Если обнаружена ситуация, когда размеры не совпали и сервер сам не может чинить — бросаем исключение.
*/
public static void runRecoveryOrThrow() {
FileStoreUtil fs = FileStoreUtil.getInstance();
BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME);
ensureDirExists(dataDir);
List<Path> tmpFiles = listTmpFiles(dataDir);
if (tmpFiles.isEmpty()) {
log.info("🟢 BlockchainTmpRecovery: временных *.tmp_bch файлов не найдено — восстановление не требуется.");
return;
}
log.warn("🟡 BlockchainTmpRecovery: найдено временных файлов: {}", tmpFiles.size());
for (Path tmpPath : tmpFiles) {
String fileName = tmpPath.getFileName().toString();
String blockchainName = extractBlockchainNameFromTmp(fileName);
if (blockchainName == null || blockchainName.isBlank()) {
// странное имя — не трогаем автоматически, но это уже повод дернуть админа
BlockchainAdminNotifier.critical(
"НАЙДЕН TMP-ФАЙЛ С НЕОЖИДАННЫМ ИМЕНЕМ: " + fileName + " (не могу определить blockchainName).",
null
);
throw new IllegalStateException("Bad tmp file name: " + fileName);
}
Path mainPath = dataDir.resolve(fs.buildBlockchainFileName(blockchainName));
long tmpSize = safeSize(tmpPath);
boolean mainExists = Files.exists(mainPath);
long mainSize = mainExists ? safeSize(mainPath) : -1L;
BlockchainStateEntry st = null;
try {
st = stateDAO.getByBlockchainName(blockchainName);
} catch (SQLException e) {
BlockchainAdminNotifier.critical(
"ОШИБКА БД ПРИ ВОССТАНОВЛЕНИИ TMP: blockchainName=" + blockchainName + " (сервер остановлен).",
e
);
throw new IllegalStateException("DB error during tmp recovery for " + blockchainName, e);
}
// ============================================================
// CASE B) state НЕТ
// ============================================================
if (st == null) {
if (!mainExists) {
// НЕТ state, НЕТ main, есть tmp => удаляем tmp
log.warn("🟠 BlockchainTmpRecovery: state отсутствует и main отсутствует, но tmp найден => удаляем tmp. blockchainName={}, tmpSize={}",
blockchainName, tmpSize);
safeDelete(tmpPath);
continue;
}
// НЕТ state, но main есть и tmp есть => это уже подозрительно
BlockchainAdminNotifier.critical(
"НЕСОГЛАСОВАННОСТЬ: ЕСТЬ main И tmp, НО НЕТ state В БД. " +
"blockchainName=" + blockchainName +
", mainSize=" + mainSize +
", tmpSize=" + tmpSize +
". СЕРВЕР ОСТАНОВЛЕН. " +
"ПОДОЗРЕНИЕ: файлы могли быть изменены вне сервера.",
null
);
throw new IllegalStateException("State missing but both main and tmp exist for " + blockchainName);
}
// ============================================================
// CASE A) state ЕСТЬ
// ============================================================
long stateSize = st.getFileSizeBytes();
// 1) stateSize == mainSize => tmp мусор
if (mainExists && mainSize == stateSize) {
log.info("🟢 BlockchainTmpRecovery: stateSize совпадает с main => tmp удаляем. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}",
blockchainName, stateSize, mainSize, tmpSize);
safeDelete(tmpPath);
continue;
}
// 2) stateSize == tmpSize => tmp это актуальная версия, ставим на место main
if (tmpSize == stateSize) {
log.warn("🟡 BlockchainTmpRecovery: stateSize совпадает с tmp => восстанавливаем main из tmp. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}",
blockchainName, stateSize, mainSize, tmpSize);
try {
// метод уже есть и делает move tmp->main с попыткой ATOMIC_MOVE
fs.atomicReplaceBlockchainFile(blockchainName);
// после move tmp должен исчезнуть сам (перемещён)
log.info("✅ BlockchainTmpRecovery: восстановление выполнено. blockchainName={}, newMainSize={}",
blockchainName, safeSize(mainPath));
} catch (Exception e) {
BlockchainAdminNotifier.critical(
"НЕ УДАЛОСЬ ВОССТАНОВИТЬ main ИЗ tmp (move failed). " +
"blockchainName=" + blockchainName +
", stateSize=" + stateSize +
", mainSize=" + mainSize +
", tmpSize=" + tmpSize +
". СЕРВЕР ОСТАНОВЛЕН.",
e
);
throw new IllegalStateException("Cannot replace main from tmp for " + blockchainName, e);
}
continue;
}
// 3) НИЧЕГО НЕ СОВПАЛО => критическая ситуация
BlockchainAdminNotifier.critical(
"ФАТАЛЬНАЯ НЕСОГЛАСОВАННОСТЬ BLOCKCHAIN ФАЙЛОВ. " +
"blockchainName=" + blockchainName +
", stateSize=" + stateSize +
", mainExists=" + mainExists +
", mainSize=" + mainSize +
", tmpSize=" + tmpSize +
". СЕРВЕР ОСТАНОВЛЕН. " +
"ТУТ НУЖНО УВЕДОМЛЕНИЕ АДМИНИСТРАТОРУ: возможно файлы изменены вручную/другой программой.",
null
);
throw new IllegalStateException("Blockchain files mismatch for " + blockchainName);
}
log.info("✅ BlockchainTmpRecovery: обработка tmp-файлов завершена.");
}
/* ===================================================================== */
/* =============================== Helpers ============================== */
/* ===================================================================== */
private static void ensureDirExists(Path dir) {
try {
if (!Files.exists(dir)) {
Files.createDirectories(dir);
}
} catch (IOException e) {
throw new IllegalStateException("Cannot create data dir: " + dir, e);
}
}
private static List<Path> listTmpFiles(Path dataDir) {
List<Path> out = new ArrayList<>();
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dataDir, "*" + FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) {
for (Path p : ds) {
if (Files.isRegularFile(p)) out.add(p);
}
} catch (IOException e) {
throw new IllegalStateException("Cannot list tmp files in: " + dataDir, e);
}
return out;
}
/**
* Из "anya0001.tmp_bch" -> "anya0001"
*/
private static String extractBlockchainNameFromTmp(String tmpFileName) {
if (tmpFileName == null) return null;
if (!tmpFileName.endsWith(FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) return null;
String base = tmpFileName.substring(0, tmpFileName.length() - FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION.length());
// базовая защита: не допускаем слэши/.. даже если кто-то подложил файл
if (base.isBlank()) return null;
if (base.contains("/") || base.contains("\\") || base.contains("..")) return null;
return base;
}
private static long safeSize(Path p) {
try {
return Files.size(p);
} catch (IOException e) {
throw new IllegalStateException("Cannot read file size: " + p, e);
}
}
private static void safeDelete(Path p) {
try {
Files.deleteIfExists(p);
} catch (IOException e) {
throw new IllegalStateException("Cannot delete file: " + p, e);
}
}
}
package server.ws;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.WriteCallback;
import org.eclipse.jetty.websocket.api.annotations.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.InboundMessageProcessor;
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.JsonInboundProcessor;
import java.nio.ByteBuffer;
import java.util.concurrent.CompletableFuture;
@WebSocket
public class BlockchainWsEndpoint {
private static final Logger log = LoggerFactory.getLogger(BlockchainWsEndpoint.class);
private Session session;
/** Контекст для текущего WebSocket-соединения. */
private final ConnectionContext connectionContext = new ConnectionContext();
@OnWebSocketConnect
public void onConnect(Session session) {
this.session = session;
// Привязываем WebSocket-сессию к ConnectionContext
connectionContext.setWsSession(session);
log.info("WS connected: {}", session.getRemoteAddress());
}
@OnWebSocketMessage
public void onBinary(byte[] payload, int offset, int length) {
byte[] msg = new byte[length];
System.arraycopy(payload, offset, msg, 0, length);
// Асинхронно обрабатываем входящее бинарное сообщение
CompletableFuture
.supplyAsync(() -> InboundMessageProcessor.process(msg))
.thenAccept(resp -> {
if (resp != null && session != null && session.isOpen()) {
session.getRemote().sendBytes(ByteBuffer.wrap(resp), new WriteCallback() {
@Override
public void writeFailed(Throwable x) {
log.warn("Failed to send response", x);
}
@Override
public void writeSuccess() {
log.debug("Response sent successfully");
}
});
}
})
.exceptionally(ex -> {
log.error("Processing failed", ex);
trySendCode(500);
return null;
});
}
private void trySendCode(int code) {
if (session != null && session.isOpen()) {
byte[] resp = InboundMessageProcessor.intTo4Bytes(code);
session.getRemote().sendBytes(ByteBuffer.wrap(resp), new WriteCallback() {
@Override
public void writeFailed(Throwable x) {
log.warn("Failed to send error code", x);
}
@Override
public void writeSuccess() {
log.debug("Error code {} sent", code);
}
});
}
}
@OnWebSocketClose
public void onClose(int statusCode, String reason) {
log.info("WS closed: {} {}", statusCode, reason);
// Удаляем это подключение из реестра активных соединений
ActiveConnectionsRegistry.getInstance().remove(connectionContext);
// На всякий случай очищаем контекст
connectionContext.reset();
}
@OnWebSocketError
public void onError(Throwable cause) {
log.error("WS error", cause);
}
// Обработка текстовых JSON-запросов
@OnWebSocketMessage
public void onText(String message) {
log.info("📥 Получено TEXT-сообщение от клиента: {}", message);
CompletableFuture
.supplyAsync(() -> JsonInboundProcessor.processJson(message, connectionContext))
.thenAccept(respJson -> {
if (respJson != null && session != null && session.isOpen()) {
log.info("📤 Отправляем ответ клиенту: {}", respJson);
session.getRemote().sendString(respJson, new WriteCallback() {
@Override
public void writeFailed(Throwable x) {
log.warn("⚠️ Не удалось отправить JSON-ответ клиенту: {}", x.toString());
}
@Override
public void writeSuccess() {
log.debug("✔ JSON-ответ успешно отправлен");
}
});
}
})
.exceptionally(ex -> {
log.error("❌ Ошибка при обработке JSON-сообщения", ex);
trySendJsonError();
return null;
});
}
private void trySendJsonError() {
if (session != null && session.isOpen()) {
String resp = "{\"op\":null,\"requestId\":null,\"status\":500,"
+ "\"payload\":{\"code\":\"INTERNAL_ERROR\",\"message\":\"Ошибка сервера\"}}";
log.info("📤 Отправляем клиенту ошибку JSON: {}", resp);
session.getRemote().sendString(resp, new WriteCallback() {
@Override
public void writeFailed(Throwable x) {
log.warn("⚠️ Не удалось отправить JSON-ответ клиенту: {}", x.toString());
}
@Override
public void writeSuccess() {
log.debug("✔ JSON-ошибка успешно отправлена");
}
});
}
}
}
package server.ws;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import utils.config.AppConfig;
import java.time.Duration;
/**
* WsServer — поднимает Jetty WS на /ws.
*
* ВАЖНО:
* - перед стартом сервера выполняем recovery tmp-блокчейнов.
* - если обнаружена несогласованность, которую сервер сам чинить не может —
* recovery бросает исключение и сервер не стартует.
*/
public final class WsServer {
private static final Logger log = LoggerFactory.getLogger(WsServer.class);
public static void main(String[] args) throws Exception {
// ============================================================
// 0) Восстановление консистентности blockchain файлов
// ============================================================
try {
BlockchainTmpRecoveryOnStartup.runRecoveryOrThrow();
} catch (Exception e) {
// Уже должно быть “большое” уведомление через BlockchainAdminNotifier,
// но на всякий случай логируем ещё раз.
log.error("❌ Сервер НЕ будет запущен: критическая ошибка восстановления blockchain tmp-файлов.", e);
throw e; // останавливаем запуск
}
// ============================================================
// 1) Настройки порта
// ============================================================
AppConfig config = AppConfig.getInstance();
int port = 7070;
try {
String portStr = config.getParam("server.port");
if (portStr != null && !portStr.isBlank()) {
port = Integer.parseInt(portStr.trim());
}
} catch (Exception e) {
log.info("Не удалось прочитать параметр server.port, используем порт по умолчанию {}", port);
}
// ============================================================
// 2) Запуск Jetty WS
// ============================================================
Server server = new Server(port);
ServletContextHandler context = new ServletContextHandler();
context.setContextPath("/");
server.setHandler(context);
// Инициализация контейнера WebSocket
JettyWebSocketServletContainerInitializer.configure(context, (servletContext, wsContainer) -> {
// Таймаут простоя соединения (Jetty 11 синтаксис)
wsContainer.setIdleTimeout(Duration.ofMinutes(5));
// Маппинг эндпоинта
wsContainer.addMapping("/ws", (req, resp) -> new BlockchainWsEndpoint());
});
server.start();
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
server.join();
}
}

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

View File

@ -1,38 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
SKIPFILE="skip.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# читаем список исключённых имён (без расширения) в массив
if [[ -f "$SKIPFILE" ]]; then
mapfile -t SKIP_LIST < "$SKIPFILE"
else
SKIP_LIST=()
fi
find . -type f -name "*.java" | sort | while read -r f; do
fname=$(basename "$f" .java) # имя файла без расширения
# проверяем, есть ли имя в списке исключений
skip=false
for skipf in "${SKIP_LIST[@]}"; do
if [[ "$fname" == "$skipf" ]]; then
skip=true
break
fi
done
if [[ "$skip" == true ]]; then
echo "Пропускаем $f"
continue
fi
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
echo "Готово! Все .java файлы собраны в $OUTFILE (кроме исключённых из $SKIPFILE)"

View File

@ -1,76 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# OUTFILE:
# - если пустая строка ("") -> в файл НЕ пишем, только в буфер
# - если не пустая -> пишем в файл + (если есть wl-copy) копируем в буфер
OUTFILE="all_files.txt"
# OUTFILE=""
# === НАСТРОЙКА: перечисляй тут пути (каталоги и/или конкретные файлы) ===
# - Если путь указывает на ФАЙЛ: берём его ВСЕГДА, даже если это не .java
# - Если путь указывает на КАТАЛОГ: рекурсивно берём только *.java внутри
# - Пустые строки игнорируются
TARGETS=(
#"./src/main/java"
# "./server"
# /home/ai/work/SHiNE/SHiNE-server/shine-server-blockchain
"/home/ai/work/SHiNE/SHiNE-server/shine-server-blockchain"
"/home/ai/work/SHiNE/SHiNE-server/shine-server-db"
)
RED=$'\033[0;31m'
RESET=$'\033[0m'
warn_red() {
echo "${RED}WARN:${RESET} $*" >&2
}
# временные файлы
TMP_LIST="$(mktemp)"
TMP_OUT="$(mktemp)"
trap 'rm -f "$TMP_LIST" "$TMP_OUT"' EXIT
# собрать пути
for path in "${TARGETS[@]}"; do
path="$(printf '%s' "$path" | sed -e 's/^[[:space:]]\+//' -e 's/[[:space:]]\+$//')"
[[ -z "$path" ]] && continue
if [[ -f "$path" ]]; then
printf '%s\n' "$path" >> "$TMP_LIST"
elif [[ -d "$path" ]]; then
find "$path" -type f -name "*.java" >> "$TMP_LIST"
else
warn_red "Не найдено (пропускаю): $path"
fi
done
# склеиваем в TMP_OUT
sort -u "$TMP_LIST" | while IFS= read -r f; do
if [[ ! -f "$f" ]]; then
warn_red "Файл исчез (пропускаю): $f"
continue
fi
cat "$f" >> "$TMP_OUT"
echo >> "$TMP_OUT"
done
# если OUTFILE не пуст — пишем файл
if [[ -n "${OUTFILE:-}" ]]; then
: > "$OUTFILE"
cat "$TMP_OUT" > "$OUTFILE"
fi
# копирование в буфер (Wayland), если доступно
if command -v wl-copy >/dev/null 2>&1; then
wl-copy < "$TMP_OUT"
else
warn_red "wl-copy не найден — в буфер не скопировано."
fi
echo "Готово!"
if [[ -n "${OUTFILE:-}" ]]; then
echo "Все файлы собраны в $OUTFILE"
else
echo "OUTFILE пуст — в файл не писали, только буфер (если wl-copy доступен)"
fi

View File

@ -1,39 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# === Список файлов (ТОЛЬКО имена без расширений) ===
# пример: Main значит Main.java, Utils значит Utils.java
NAMES=(
"IT_04_UserParams_NoAuth"
"AddBlockSender"
"ChainState"
"JsonBuilders"
)
# очищаем или создаём файл
: > "$OUTFILE"
# Быстрый фильтр: сделаем хеш-таблицу из имён (ассоц. массив)
declare -A WANT=()
for name in "${NAMES[@]}"; do
WANT["$name"]=1
done
# собрать только нужные *.java по базовому имени
find . -type f -name "*.java" | sort | while read -r f; do
base="$(basename "$f" .java)"
if [[ -n "${WANT[$base]+x}" ]]; then
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
fi
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Выбрано имён: ${#NAMES[@]}"
echo "Все нужные .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

View File

@ -160,9 +160,9 @@ public class IT_01_AddUser {
String blockchainName = JsonParsers.userBlockchainName(resp); String blockchainName = JsonParsers.userBlockchainName(resp);
String solanaKey = JsonParsers.userSolanaKey(resp); String solanaKey = JsonParsers.userSolanaKey(resp);
String blockchainKey = JsonParsers.userBlockchainKey(resp); String blockchainKey = JsonParsers.userBlockchainKey(resp);
String deviceKey = JsonParsers.userDeviceKey(resp); String clientKey = JsonParsers.userClientKey(resp);
if (isBlank(login) || isBlank(blockchainName) || isBlank(solanaKey) || isBlank(blockchainKey) || isBlank(deviceKey)) { if (isBlank(login) || isBlank(blockchainName) || isBlank(solanaKey) || isBlank(blockchainKey) || isBlank(clientKey)) {
r.fail("GetUser: exists=true, но поля пустые/неполные, resp=" + resp); r.fail("GetUser: exists=true, но поля пустые/неполные, resp=" + resp);
fail("GetUser returned incomplete user data"); fail("GetUser returned incomplete user data");
} }
@ -187,7 +187,7 @@ public class IT_01_AddUser {
// ключи должны совпадать с теми, что AddUser использует при регистрации // ключи должны совпадать с теми, что AddUser использует при регистрации
String expSol = TestConfig.solanaPublicKeyB64(canonical); String expSol = TestConfig.solanaPublicKeyB64(canonical);
String expBchKey = TestConfig.blockchainPublicKeyB64(canonical); String expBchKey = TestConfig.blockchainPublicKeyB64(canonical);
String expDev = TestConfig.devicePublicKeyB64(canonical); String expDev = TestConfig.clientPublicKeyB64(canonical);
if (!solanaKey.equals(expSol)) { if (!solanaKey.equals(expSol)) {
r.fail("GetUser: solanaKey mismatch, resp=" + resp); r.fail("GetUser: solanaKey mismatch, resp=" + resp);
@ -197,9 +197,9 @@ public class IT_01_AddUser {
r.fail("GetUser: blockchainKey mismatch, resp=" + resp); r.fail("GetUser: blockchainKey mismatch, resp=" + resp);
fail("GetUser blockchainKey mismatch"); fail("GetUser blockchainKey mismatch");
} }
if (!deviceKey.equals(expDev)) { if (!clientKey.equals(expDev)) {
r.fail("GetUser: deviceKey mismatch, resp=" + resp); r.fail("GetUser: clientKey mismatch, resp=" + resp);
fail("GetUser deviceKey mismatch"); fail("GetUser clientKey mismatch");
} }
} }
@ -270,7 +270,7 @@ public class IT_01_AddUser {
"blockchainName": "%s", "blockchainName": "%s",
"solanaKey": "%s", "solanaKey": "%s",
"blockchainKey": "%s", "blockchainKey": "%s",
"deviceKey": "%s", "clientKey": "%s",
"bchLimit": %d "bchLimit": %d
} }
} }

Some files were not shown because too many files have changed in this diff Show More