homeserver: рендейм subserver→homeserver, документ деривации ключей, запрет пустого пароля

Основное (наша работа в этой сессии):
- Переименование «subserver» → «homeserver» по всему проекту: основной ESP32-скетч
  (папка shine_subserver_ui → shine_homeserver_ui, .ino, flash-скрипт, режим burn.sh
  homeserver-ui), скетч lvgl_nav_minimal_test (ключ homeserver.key:<имя>), spec-доки
  reference/*, формат PDA (терминология session_type=100 «Homeserver пользователя»),
  константа SESSION_TYPE_HOMESERVER в JS и Rust (значение 100 не менялось, формат не затронут),
  pending/future доки, AGENTS.md, DAO-док. Сохранены отдельный lvgl_subserver_touch_test и
  историческая пометка о рендейме в DERIVATION.md.
- Новый источник истины по деривации ключей: Dev_Docs/Keys/DERIVATION.md (Argon2id-секрет из
  пароля, формула Ed25519(SHA-256(base64(secret)|suffix)), суффиксы root/bch/dev/homeserver.key,
  Solana-ключ = dev.key). Уточнены роли root (главный/master) и dev (пополняемый кошелёк) в
  Dev_Docs/Keys/README.md.
- UI: убран легаси-путь пустого пароля (derivePasswordSeed и др.), deriveMasterSecretFromPassword
  бросает ошибку на пустом пароле, register-view блокирует пустой пароль; экран пополнения
  переведён на канонический device-адрес из preGeneratedKeyBundle (удалён расходящийся
  deriveWalletFromPassword).

Включены также параллельные правки Solana-аудита №3 (были в рабочем дереве, переплетены в lib.rs):
- shine_users: defense-in-depth «строгий список аккаунтов» (require!(it.next().is_none()))
  в init/update economy config и create/update user PDA, плюс описание в doc/programs/shine_users.md;
- Dev_Docs/audit/Solana-audit-3-by-Claude-12июня2026.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AidarKC 2026-06-12 21:16:12 +04:00
parent cf6a2830c8
commit 42dcf6970d
32 changed files with 517 additions and 251 deletions

View File

@ -24,12 +24,12 @@
- Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `SHiNE-agent-bot-coder/AGENT.md`. - Подробные служебные правила Telegram-обработчика, его очередь, история, systemd-запуск и особенности ответов описывать в `SHiNE-agent-bot-coder/AGENT.md`.
- Если в сообщениях пользователя встречается «агент MD» или похожая формулировка про файл инструкций Codex, считать, что имеется в виду автоматически читаемый `AGENTS.md`. - Если в сообщениях пользователя встречается «агент MD» или похожая формулировка про файл инструкций Codex, считать, что имеется в виду автоматически читаемый `AGENTS.md`.
## ESP32 UI сабсервера ## ESP32 UI homeserver
- Для UI-скетча устройства `ESP32-S3-Touch-AMOLED-2.16` документ-спецификация и Arduino-скетч должны всегда оставаться синхронными. - Для UI-скетча устройства `ESP32-S3-Touch-AMOLED-2.16` документ-спецификация и Arduino-скетч должны всегда оставаться синхронными.
- Актуальный документ по экранной логике, состояниям, кнопкам, полям, статусам и ограничениям UI считать источником истины для скетча. - Актуальный документ по экранной логике, состояниям, кнопкам, полям, статусам и ограничениям UI считать источником истины для скетча.
- При изменении документа обязательно в том же наборе изменений приводить в соответствие скетч. - При изменении документа обязательно в том же наборе изменений приводить в соответствие скетч.
- При изменении скетча обязательно в том же наборе изменений обновлять документ, если поменялись экраны, тексты, переходы, статусы, кнопки, поля или поведение. - При изменении скетча обязательно в том же наборе изменений обновлять документ, если поменялись экраны, тексты, переходы, статусы, кнопки, поля или поведение.
- Для нового ESP32 UI-прототипа сабсервера использовать русский язык как основной и отдельно следить, чтобы текст реально отображался на устройстве, а не только логически присутствовал в коде. - Для нового ESP32 UI-прототипа homeserver использовать русский язык как основной и отдельно следить, чтобы текст реально отображался на устройстве, а не только логически присутствовал в коде.
## Solana-модуль ## Solana-модуль
- В проекте есть отдельный Solana/Anchor-модуль в папке `shine-solana/shine/`. - В проекте есть отдельный Solana/Anchor-модуль в папке `shine-solana/shine/`.

View File

@ -155,11 +155,11 @@
- это обязательный шаг перед переходом от "собрали" к "доверяем". - это обязательный шаг перед переходом от "собрали" к "доверяем".
### 3. Устройство на ESP32 как сабсервер с ключами ### 3. Устройство на ESP32 как homeserver с ключами
Что сделать: Что сделать:
- дописать прошивку, чтобы устройство могло выступать сабсервером с ключами; - дописать прошивку, чтобы устройство могло выступать homeserver с ключами;
- дать ему возможность регистрироваться и подключаться к серверу; - дать ему возможность регистрироваться и подключаться к серверу;
- определить, какие операции устройство подписывает и где хранит ключевой материал. - определить, какие операции устройство подписывает и где хранит ключевой материал.

View File

@ -37,7 +37,7 @@
- `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн. - `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн.
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений. - `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
- `medium/2026-06-03_подключениеругих_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать. - `medium/2026-06-03_подключениеругих_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать.
- `medium/2026-06-02_сессионные_саб_серверы_в_pda.md` - несколько саб-серверов пользователя как типизированные сессии в PDA с версией записи. - `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи.
### DAO-запуск ### DAO-запуск

View File

@ -1,4 +1,4 @@
# Сессионные саб-серверы в PDA пользователя # Сессионные homeserver-ы в PDA пользователя
- Статус: - Статус:
`future` `future`
@ -10,15 +10,15 @@
после завершения первого этапа по пользовательским сессиям после завершения первого этапа по пользовательским сессиям
- Основание: - Основание:
Идея зафиксирована после обсуждения архитектуры пользовательских сессий и внутренних саб-серверов. Сейчас задача сознательно отложена: сначала нужно аккуратно ввести базовую модель сессий, а затем возвращаться к расширенной серверной роли. Идея зафиксирована после обсуждения архитектуры пользовательских сессий и внутренних homeserver-ов. Сейчас задача сознательно отложена: сначала нужно аккуратно ввести базовую модель сессий, а затем возвращаться к расширенной серверной роли.
## Зачем нужна фича ## Зачем нужна фича
У одного пользователя может быть несколько доверенных внутренних саб-серверов, и каждый из них должен жить как отдельная пользовательская сессия, а не как отдельная особая сущность вне общей модели. У одного пользователя может быть несколько доверенных внутренних homeserver-ов, и каждый из них должен жить как отдельная пользовательская сессия, а не как отдельная особая сущность вне общей модели.
Это нужно, чтобы: Это нужно, чтобы:
- хранить несколько саб-серверов у одного пользователя одновременно; - хранить несколько homeserver-ов у одного пользователя одновременно;
- различать обычные клиентские сессии и серверные сессии по явному типу; - различать обычные клиентские сессии и серверные сессии по явному типу;
- дать расширяемый формат записи с версией; - дать расширяемый формат записи с версией;
- использовать единый подход для DM, звонков и внутренних команд между сессиями. - использовать единый подход для DM, звонков и внутренних команд между сессиями.
@ -35,18 +35,18 @@
Предварительные значения: Предварительные значения:
- тип `1` - обычная пользовательская сессия; - тип `1` - обычная пользовательская сессия;
- тип `100` - саб-сервер пользователя; - тип `100` - homeserver пользователя;
- версия `1` - первая рабочая версия формата записи сессии. - версия `1` - первая рабочая версия формата записи сессии.
На текущем этапе под это уже зарезервирован отдельный блок `SessionsBlock` с `block_type = 55`, а `TrustedStateBlock` остаётся на `50`. На текущем этапе под это уже зарезервирован отдельный блок `SessionsBlock` с `block_type = 55`, а `TrustedStateBlock` остаётся на `50`.
Важно: саб-серверов у одного пользователя может быть несколько. Важно: homeserver-ов у одного пользователя может быть несколько.
## Архитектурный принцип ## Архитектурный принцип
Внутренний протокол взаимодействия должен оставаться транспортным. Внутренний протокол взаимодействия должен оставаться транспортным.
То есть SHiNE-сервер не должен разбирать прикладной смысл внутренней нагрузки саб-сервера, а должен: То есть SHiNE-сервер не должен разбирать прикладной смысл внутренней нагрузки homeserver-а, а должен:
- доставлять сообщения между сессиями; - доставлять сообщения между сессиями;
- доставлять сигналы звонков между сессиями; - доставлять сигналы звонков между сессиями;
@ -60,7 +60,7 @@
- Вызов звонка уже рассылается по нескольким активным сессиям пользователя. - Вызов звонка уже рассылается по нескольким активным сессиям пользователя.
- Сигналы звонка уже адресуются конкретной сессии, а stop-сигналы дублируются на остальные сессии того же пользователя. - Сигналы звонка уже адресуются конкретной сессии, а stop-сигналы дублируются на остальные сессии того же пользователя.
Иными словами, текущая серверная логика ближе к модели "сервер доставляет между сессиями", чем к модели "сервер понимает внутренний протокол саб-сервера". Иными словами, текущая серверная логика ближе к модели "сервер доставляет между сессиями", чем к модели "сервер понимает внутренний протокол homeserver-а".
## Что нужно сделать при возврате к задаче ## Что нужно сделать при возврате к задаче
@ -77,7 +77,7 @@
- правила удаления и обновления записи; - правила удаления и обновления записи;
- правила ротации `sessionPubKey`. - правила ротации `sessionPubKey`.
6. Продумать, как UI и сервер будут отличать тип `1` и тип `100`. 6. Продумать, как UI и сервер будут отличать тип `1` и тип `100`.
7. Определить, какие внутренние сообщения саб-сервера останутся полностью прозрачными для SHiNE-сервера, а какие потребуют только технической маршрутизации. 7. Определить, какие внутренние сообщения homeserver-а останутся полностью прозрачными для SHiNE-сервера, а какие потребуют только технической маршрутизации.
8. Добавить API/операции чтения и обновления списка сессий, если для этого не хватит существующих механизмов. 8. Добавить API/операции чтения и обновления списка сессий, если для этого не хватит существующих механизмов.
9. После реализации обязательно обновить документацию. 9. После реализации обязательно обновить документацию.
@ -101,5 +101,5 @@
Продолжать после завершения первой части: Продолжать после завершения первой части:
1. описать минимальный формат записи пользовательской сессии; 1. описать минимальный формат записи пользовательской сессии;
2. отдельно решить, живут ли саб-серверы в том же списке, что и обычные сессии; 2. отдельно решить, живут ли homeserver-ы в том же списке, что и обычные сессии;
3. затем уже проектировать операции регистрации, обновления и отключения таких сессий. 3. затем уже проектировать операции регистрации, обновления и отключения таких сессий.

154
Dev_Docs/Keys/DERIVATION.md Normal file
View File

@ -0,0 +1,154 @@
# Деривация секрета и ключей SHiNE (формулы)
> **Статус: ИСТОЧНИК ИСТИНЫ (single source of truth) по конкретной деривации.**
> Этот файл описывает, как из пароля получается секрет и как из секрета выводятся
> все ключи (root, blockchain, device/Solana, homeserver) — формулами, байт-в-байт.
> Если в коде меняется деривация (формула секрета, параметры Argon2id, соль, формула
> ключа, разделитель `|`, набор/имена суффиксов, формат homeserver-ключа, связь
> dev-ключ ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**.
> Роли и назначение ключей описаны отдельно в `Dev_Docs/Keys/README.md` (архитектура).
> Здесь — только механика. Документ намеренно краткий.
---
## 1. Секрет (masterSecret)
`masterSecret` — 32 байта. Два источника:
**А. Из пароля пользователя (основной путь, UI).**
```
login = trim(lowercase(login))
salt = SHA-256("shine-auth-v2|login=" + login + "|suffix=master.secret")[0..16) // первые 16 байт
material = utf8(login + "\n" + password)
masterSecret(32) = Argon2id(material, salt, t=2, m=65536 KiB, p=1, dkLen=32)
```
- Параметры Argon2id фиксированы: `t=2`, `m=65536` (64 МиБ), `p=1`, `dkLen=32`.
- Логин входит и в соль, и в начало `material` (склейка через `\n`).
- Пустой пароль **запрещён**: легаси-fallback без Argon2 удалён, `deriveMasterSecretFromPassword` бросает ошибку на пустом пароле, а форма регистрации в UI блокирует пустой пароль (`register-view.js`).
**Б. Случайный (прошивка ESP32, новый аккаунт без пароля).**
```
masterSecret(32) = 32 случайных байта (esp_random) // хранится на устройстве как base58
```
Дальше деривация ключей одинакова независимо от источника секрета.
---
## 2. Производные ключи
Все ключи выводятся из `masterSecret` по **одной формуле**, отличается только суффикс:
```
material = base64_std(masterSecret) + "|" + <суффикс>
seed(32) = SHA-256(material)
(pub, priv) = Ed25519_keypair_from_seed(seed)
```
- `base64_std` — стандартный base64 (не url-safe).
- Разделитель — символ `|`.
- Суффиксы значимы байт-в-байт (регистр и точки важны).
| Ключ | Суффикс | Назначение (кратко) |
|------|---------|---------------------|
| root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). |
| blockchain | `bch.key` | Подписывает `LastBlockState` персонального блокчейна (`blockchain_public_key`). |
| device / **Solana** | `dev.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(devicePub)`. См. §3. |
| homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. |
Полные роли каждого ключа — в `Dev_Docs/Keys/README.md`.
---
## 3. Solana-ключ
Отдельного «солана-ключа» нет. На Solana работают два ключа:
- **`dev.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(devicePub)`.
Этим ключом оплачиваются и подписываются `create_user_pda` / `update_user_pda`.
Пополнять SOL нужно именно на этот адрес.
- **`root.key` — авторитет записи**, подписывает unsigned-часть PDA через Ed25519-инструкцию, но **не** является fee payer.
Соответствует формату PDA `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` §2.1
(«create/update оплачиваются с `device_key`», «root_key — не fee payer»).
Кратко про роли на Solana: `root.key` — это **главный (master) ключ**: им управляют PDA-записью
(`create/update`) и через это можно заменить все остальные ключи; `dev.key` — это **пополняемый
кошелёк и плательщик комиссий**. Полное описание ролей — `Dev_Docs/Keys/README.md`.
---
## 4. Ключи homeserver
У пользователя может быть несколько homeserver-ов. Каждый имеет **своё имя** и **свой приватный ключ**,
выведенный из секрета по той же формуле с именованным суффиксом:
```
suffix = "homeserver.key:" + <имя homeserver> // имя по умолчанию: "homeserver1"
material = base64_std(masterSecret) + "|" + suffix
seed(32) = SHA-256(material)
(pub, priv) = Ed25519_keypair_from_seed(seed)
```
Пример для двух homeserver-ов:
```
homeserver.key:home-a -> ключ A
homeserver.key:home-b -> ключ B
```
Публичный ключ homeserver-а публикуется в `SessionsBlock` пользовательской PDA как
`session_pub_key` с `session_type = 100`, имя — в `session_name` (формат PDA §13).
> Это переименование прежней схемы `subserver.key:<имя>``homeserver.key:<имя>`.
> Термин «саб-сервер» по проекту заменяется на «homeserver».
---
## 5. Где это в коде
### Деривация секрета и ключей (UI, каноническая)
- `shine-UI/js/services/crypto-utils.js`
- секрет из пароля: `makeArgon2Salt`, `deriveMasterSecretArgon2id`, `deriveMasterSecretFromPassword` (~129218);
- ключ из секрета: `deriveEd25519FromMasterSecret` (~220).
- `shine-UI/js/services/auth-service.js` — набор root/bch/dev из `masterSecret` (~732758).
- `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147160).
### Solana-ключ / адрес кошелька (UI)
- `shine-UI/js/pages/registration-payment-view.js``deriveUserWalletAddress`: адрес = `base58(devicePub)` (~113).
- `shine-UI/js/pages/topup-view.js``deviceWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.devicePair`.
Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `dev.key`, мимо `masterSecret`) удалён.
### Деривация ключей (прошивка ESP32)
- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_homeserver_ui/shine_homeserver_ui.ino`
- `deriveKeysFromMasterSecret` (~782), `restoreDerivedKeysFromSecret` (~806), `deriveFreshSecretAndWallet` (~829);
- регистрация/подпись Solana: `registerHomeserverOnSolana` (~1182), `signMessageEd25519` (~1147).
- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino`
- homeserver-ключ: `homeserverKeySuffix` (~690), `deriveKeyPairFromSecretSuffix` (~699), `refreshDerivedKeys` (~725); суффикс `homeserver.key:<имя>`.
### Формат PDA (куда попадают ключи)
- `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.
### Сервер (тестовый seed)
- `SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java` `deriveKeysFromPassword` (~246) —
выводит ключи как `Ed25519(SHA-256(base64(SHA-256(password)) + suffix))`, **без** Argon2 и **без** разделителя `|`.
Это **не баг**, а точное повторение легаси-пути UI `derivePasswordSeed` (для пустого пароля), у которого тоже нет `|`.
С современным путём `masterSecret`-bundle (Argon2 + `base64(secret)|suffix`) он **не совпадает** by design.
Если потребуется, чтобы seed совпадал с реальными клиентами на Argon2 — нужно отдельно портировать
Argon2id+masterSecret в Java (на сервере Argon2 сейчас нет). Простое добавление `|` было бы **неверным**:
сломало бы совпадение с легаси-путём и всё равно не дало бы совпадения с Argon2-путём.
---
## 6. Правило синхронизации (обязательно)
1. Этот документ — источник истины по деривации секрета и ключей.
2. Любое изменение кода, затрагивающее формулу секрета, параметры Argon2id, соль, формулу ключа,
разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь dev-ключ ↔ Solana-адрес —
**обязательно** отражать здесь в том же изменении.
3. Пункты, помеченные ⚠️, — это долг к устранению, а не норма.
4. Нельзя сознательно оставлять код и этот документ в рассинхроне без отдельной явной договорённости.

View File

@ -8,7 +8,7 @@
В SHiNE у пользователя есть несколько уровней ключей: В SHiNE у пользователя есть несколько уровней ключей:
- `root key` - главный корневой ключ пользователя, он же основной Solana-ключ. - `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `device key`).
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя. - `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя.
- `device key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей. - `device key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей.
- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере. - `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере.
@ -28,9 +28,9 @@
- управление остальными ключами; - управление остальными ключами;
- подтверждение операций, которые должны иметь максимальный уровень доверия. - подтверждение операций, которые должны иметь максимальный уровень доверия.
В текущей модели `root key` совпадает по смыслу с главным Solana-ключом пользователя. `root key` — это **главный (master) ключ** в следующем смысле: зная `root key`, можно управлять пользовательской PDA-записью в Solana (`create_user_pda` / `update_user_pda`) и тем самым **заменить все остальные ключи** пользователя (device, blockchain, homeserver). Поэтому компрометация `root key` равносильна компрометации всей личности пользователя.
На `root key` могут храниться значимые средства, если пользователь сознательно выбирает такую модель. Для мелких текущих расходов предпочтительнее использовать `device key`. Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `device key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `Dev_Docs/Keys/DERIVATION.md`, §3.
## `blockchain key` ## `blockchain key`
@ -158,6 +158,7 @@ Self-message - это сообщение пользователя самому
## Связанные документы ## Связанные документы
- `Dev_Docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`dev.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 и движения средств.

View File

@ -1,26 +1,26 @@
# ESP32 UI-прототип сабсервера SHiNE # ESP32 UI-прототип homeserver SHiNE
- краткое описание фичи: - краткое описание фичи:
для `Waveshare ESP32-S3-Touch-AMOLED-2.16` добавлен новый интерактивный UI-скетч сабсервера `SHiNE` с хранением данных в `NVS`, настройками `Wi-Fi`, настройками серверов, кошельком, экраном `QR/URI`, живой Solana-регистрацией и экраном входящих запросов. Логика PIN в коде сохранена, но вход по PIN во временной сборке отключён, чтобы не блокировать проверку остальных экранов. В текущей версии `Wi-Fi` подключается реально, адреса `API/RPC/WS` проверяются реально, баланс кошелька читается из `Solana RPC`, а регистрация отправляет `create_user_pda` в `shine_users`. для `Waveshare ESP32-S3-Touch-AMOLED-2.16` добавлен новый интерактивный UI-скетч homeserver `SHiNE` с хранением данных в `NVS`, настройками `Wi-Fi`, настройками серверов, кошельком, экраном `QR/URI`, живой Solana-регистрацией и экраном входящих запросов. Логика PIN в коде сохранена, но вход по PIN во временной сборке отключён, чтобы не блокировать проверку остальных экранов. В текущей версии `Wi-Fi` подключается реально, адреса `API/RPC/WS` проверяются реально, баланс кошелька читается из `Solana RPC`, а регистрация отправляет `create_user_pda` в `shine_users`.
- что именно проверять: - что именно проверять:
1. Прошить режим `subserver-ui` и дождаться открытия главного экрана без PIN. 1. Прошить режим `homeserver-ui` и дождаться открытия главного экрана без PIN.
2. Проверить, что текст в заголовках, кнопках и статусах отображается читаемо; в текущей временной сборке допускается ASCII-транслитерация русского текста. 2. Проверить, что текст в заголовках, кнопках и статусах отображается читаемо; в текущей временной сборке допускается ASCII-транслитерация русского текста.
3. Открыть `Настройки` и убедиться, что показывается пометка о временно отключённом входе по PIN. 3. Открыть `Настройки` и убедиться, что показывается пометка о временно отключённом входе по PIN.
4. Открыть `Подключение -> Wi-Fi`, ввести `SSID` и пароль, нажать `Проверить`, дождаться реального подключения, затем перезагрузить устройство и проверить, что значения сохранились. 4. Открыть `Подключение -> Wi-Fi`, ввести `SSID` и пароль, нажать `Проверить`, дождаться реального подключения, затем перезагрузить устройство и проверить, что значения сохранились.
5. Открыть `Подключение -> Серверы`, проверить или изменить `API/RPC/WS`, нажать `Проверить` и убедиться, что показываются реальные статусы доступности, затем перезагрузить устройство и проверить сохранение значений. 5. Открыть `Подключение -> Серверы`, проверить или изменить `API/RPC/WS`, нажать `Проверить` и убедиться, что показываются реальные статусы доступности, затем перезагрузить устройство и проверить сохранение значений.
6. Открыть `Аккаунт`, ввести логин, имя сабсервера и нажать `Сгенерировать`; проверить, что появились секрет и адрес кошелька, а после перезагрузки они не исчезают. 6. Открыть `Аккаунт`, ввести логин, имя homeserver и нажать `Сгенерировать`; проверить, что появились секрет и адрес кошелька, а после перезагрузки они не исчезают.
7. Открыть `Кошелёк`, нажать `Проверить` и убедиться, что баланс реально читается из `Solana RPC`; затем открыть `QR и URI` и проверить, что QR-код отрисовывается и сканируется как `solana:`-ссылка. 7. Открыть `Кошелёк`, нажать `Проверить` и убедиться, что баланс реально читается из `Solana RPC`; затем открыть `QR и URI` и проверить, что QR-код отрисовывается и сканируется как `solana:`-ссылка.
8. При необходимости отдельно проверить тестовые кнопки `+/- SOL`: они меняют локальный баланс для UX-сценариев, но после следующей реальной RPC-проверки баланс должен вернуться к сетевому значению. 8. При необходимости отдельно проверить тестовые кнопки `+/- SOL`: они меняют локальный баланс для UX-сценариев, но после следующей реальной RPC-проверки баланс должен вернуться к сетевому значению.
9. Вернуться на главный экран и проверить, что до выполнения всех условий кнопка регистрации недоступна, а после выполнения становится доступной. 9. Вернуться на главный экран и проверить, что до выполнения всех условий кнопка регистрации недоступна, а после выполнения становится доступной.
10. Выполнить регистрацию и убедиться, что статус меняется на `Сабсервер активен`, онлайн-статус становится активным, а на экране появляются краткие отпечатки `PDA/TX`. 10. Выполнить регистрацию и убедиться, что статус меняется на `Homeserver активен`, онлайн-статус становится активным, а на экране появляются краткие отпечатки `PDA/TX`.
11. После регистрации проверить через `Solana`/UI проекта, что `user_pda` для этого логина реально создана и соответствует `device`-адресу устройства. 11. После регистрации проверить через `Solana`/UI проекта, что `user_pda` для этого логина реально создана и соответствует `device`-адресу устройства.
12. Открыть `Запросы`, поочерёдно открыть оба демонстрационных запроса и проверить, что кнопки `Разрешить` и `Отклонить` меняют их статус. 12. Открыть `Запросы`, поочерёдно открыть оба демонстрационных запроса и проверить, что кнопки `Разрешить` и `Отклонить` меняют их статус.
13. При необходимости открыть `Настройки -> Сменить PIN` и убедиться, что новый PIN сохраняется, хотя вход по PIN временно не используется на старте. 13. При необходимости открыть `Настройки -> Сменить PIN` и убедиться, что новый PIN сохраняется, хотя вход по PIN временно не используется на старте.
14. Выполнить `Полный сброс` и убедиться, что все поля, секрет, баланс, онлайн и регистрация очищаются. 14. Выполнить `Полный сброс` и убедиться, что все поля, секрет, баланс, онлайн и регистрация очищаются.
- ожидаемый результат: - ожидаемый результат:
новый `ESP32`-скетч стабильно запускается, показывает читаемый интерфейс хотя бы в ASCII-транслитерации, сохраняет данные во внутренней памяти устройства, реально подключается к `Wi-Fi`, реально проверяет `API/RPC/WS`, реально читает баланс из `Solana RPC`, рисует рабочий `QR` для `solana:`-URI и позволяет вручную пройти полный сценарий on-chain регистрации сабсервера. новый `ESP32`-скетч стабильно запускается, показывает читаемый интерфейс хотя бы в ASCII-транслитерации, сохраняет данные во внутренней памяти устройства, реально подключается к `Wi-Fi`, реально проверяет `API/RPC/WS`, реально читает баланс из `Solana RPC`, рисует рабочий `QR` для `solana:`-URI и позволяет вручную пройти полный сценарий on-chain регистрации homeserver.
- статус: - статус:
pending pending

View File

@ -1,13 +1,13 @@
# ESP32 авто-прошивка shine_subserver_ui # ESP32 авто-прошивка shine_homeserver_ui
- краткое описание фичи: - краткое описание фичи:
добавлен исполняемый скрипт `flash_shine_subserver_ui.sh`, который автоматически ищет USB-порт `ESP32` и запускает заливку прошивки `shine_subserver_ui` без ручного указания `PORT`. добавлен исполняемый скрипт `flash_shine_homeserver_ui.sh`, который автоматически ищет USB-порт `ESP32` и запускает заливку прошивки `shine_homeserver_ui` без ручного указания `PORT`.
- что именно проверять: - что именно проверять:
1. Подключить плату `ESP32` по USB. 1. Подключить плату `ESP32` по USB.
2. Перейти в папку `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/`. 2. Перейти в папку `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/`.
3. Запустить `./flash_shine_subserver_ui.sh`. 3. Запустить `./flash_shine_homeserver_ui.sh`.
4. Убедиться, что скрипт сам показывает найденный порт и успешно запускает compile/upload. 4. Убедиться, что скрипт сам показывает найденный порт и успешно запускает compile/upload.
- ожидаемый результат: - ожидаемый результат:
скрипт без ручного ввода порта находит `ESP32`, печатает найденный `/dev/ttyACM*` или `/dev/ttyUSB*` и заливает `shine_subserver_ui`. скрипт без ручного ввода порта находит `ESP32`, печатает найденный `/dev/ttyACM*` или `/dev/ttyUSB*` и заливает `shine_homeserver_ui`.
- статус: - статус:
pending pending

View File

@ -1,7 +1,7 @@
# ESP32 PIN-клавиатура: подписи кнопок # ESP32 PIN-клавиатура: подписи кнопок
- краткое описание фичи: - краткое описание фичи:
в UI-скетче `shine_subserver_ui` изменена отрисовка подписей кнопок. Вместо малого шрифта теперь используется более стабильный шрифт с явным центрированием текста внутри кнопок, чтобы на экране ввода PIN и других экранах не пропадали цифры и надписи. в UI-скетче `shine_homeserver_ui` изменена отрисовка подписей кнопок. Вместо малого шрифта теперь используется более стабильный шрифт с явным центрированием текста внутри кнопок, чтобы на экране ввода PIN и других экранах не пропадали цифры и надписи.
- что именно проверять: - что именно проверять:
1. Включить устройство и дождаться экрана ввода PIN. 1. Включить устройство и дождаться экрана ввода PIN.
2. Убедиться, что на всех серых кнопках видны цифры `0-9`, `Отмена` и `OK`. 2. Убедиться, что на всех серых кнопках видны цифры `0-9`, `Отмена` и `OK`.

View File

@ -6,8 +6,8 @@
1. Запустить `./burn.sh gfx-text-test` и убедиться, что прошивается тест текста из новой папки. 1. Запустить `./burn.sh gfx-text-test` и убедиться, что прошивается тест текста из новой папки.
2. Запустить `./burn.sh gfx-layout-test` и проверить нижние ряды кнопок. 2. Запустить `./burn.sh gfx-layout-test` и проверить нижние ряды кнопок.
3. Запустить `./burn.sh lvgl-basic-test` и проверить, что `LVGL` показывает текст и кнопки. 3. Запустить `./burn.sh lvgl-basic-test` и проверить, что `LVGL` показывает текст и кнопки.
4. Убедиться, что новая папка не мешает сборке `subserver-ui`. 4. Убедиться, что новая папка не мешает сборке `homeserver-ui`.
- ожидаемый результат: - ожидаемый результат:
тестовые скетчи лежат отдельно от основного UI, шьются отдельными режимами и позволяют быстро проверять разные гипотезы по экрану без правок в `shine_subserver_ui`. тестовые скетчи лежат отдельно от основного UI, шьются отдельными режимами и позволяют быстро проверять разные гипотезы по экрану без правок в `shine_homeserver_ui`.
- статус: - статус:
pending pending

View File

@ -1,9 +1,9 @@
# ESP32 nav minimal test # ESP32 nav minimal test
- Краткое описание: минимальный UI-прототип для сабсервера на базе `LVGL + subserver touch`, с Wi-Fi flow, серверными адресами и общим экраном редактирования текста. - Краткое описание: минимальный UI-прототип для homeserver на базе `LVGL + subserver touch`, с Wi-Fi flow, серверными адресами и общим экраном редактирования текста.
- Что проверять: - Что проверять:
- стартует экран `HOME`; - стартует экран `HOME`;
- на `HOME` видны реальное значение сабсервера или `subserver not set`, реальное значение логина или `login not set`, при отсутствии секрета строка `secret not set`, а также `STATUS`, верхний правый блок с процентом батареи, иконкой батареи и индикатором Wi-Fi, кнопка баланса, строка `SHiNE: ...`, кнопка `SETTINGS` уменьшенной ширины у правого края и нижняя подпись `SHiNE subserver (v.0.18)`; - на `HOME` видны реальное значение homeserver или `homeserver not set`, реальное значение логина или `login not set`, при отсутствии секрета строка `secret not set`, а также `STATUS`, верхний правый блок с процентом батареи, иконкой батареи и индикатором Wi-Fi, кнопка баланса, строка `SHiNE: ...`, кнопка `SETTINGS` уменьшенной ширины у правого края и нижняя подпись `SHiNE homeserver (v.0.18)`;
- справа от строки логина виден индикатор статуса Solana-аккаунта: - справа от строки логина виден индикатор статуса Solana-аккаунта:
- зелёный, если ключи совпали; - зелёный, если ключи совпали;
- красный, если mismatch; - красный, если mismatch;
@ -19,9 +19,9 @@
- `unavailable` - `unavailable`
- пока открыт `HOME`, статус сам обновляется без перехода на другие экраны; - пока открыт `HOME`, статус сам обновляется без перехода на другие экраны;
- баланс обновляется кнопкой по нажатию; - баланс обновляется кнопкой по нажатию;
- если логин зарегистрирован и секрет/сабсервер заданы, устройство: - если логин зарегистрирован и секрет/homeserver заданы, устройство:
- читает `user_pda` через Solana RPC; - читает `user_pda` через Solana RPC;
- сверяет `root`, `blockchain`, `device` и `subserver` session type `100`; - сверяет `root`, `blockchain`, `device` и `homeserver` session type `100`;
- поднимает WebSocket-сессию с сервером SHiNE; - поднимает WebSocket-сессию с сервером SHiNE;
- шлёт `Ping` раз в минуту; - шлёт `Ping` раз в минуту;
- кнопка `SETTINGS` открывает `SETTINGS_MENU`; - кнопка `SETTINGS` открывает `SETTINGS_MENU`;
@ -54,7 +54,7 @@
- визуальный курсор в поле ввода не показывается; - визуальный курсор в поле ввода не показывается;
- новые символы всегда дописываются только в конец строки; - новые символы всегда дописываются только в конец строки;
- основные 3 ряда клавиш и нижний служебный ряд стали выше; - основные 3 ряда клавиш и нижний служебный ряд стали выше;
- внизу остаётся отдельная тёмная полоса с версией `SHiNE subserver (v.0.18)`, а рамка клавиатурного блока заканчивается выше неё; - внизу остаётся отдельная тёмная полоса с версией `SHiNE homeserver (v.0.18)`, а рамка клавиатурного блока заканчивается выше неё;
- одно непрерывное касание вызывает не более одного действия кнопки; - одно непрерывное касание вызывает не более одного действия кнопки;
- скольжение пальцем по клавиатуре не нажимает подряд несколько клавиш; - скольжение пальцем по клавиатуре не нажимает подряд несколько клавиш;
- медленный свайп по экрану не должен превращаться в случайное нажатие кнопки; - медленный свайп по экрану не должен превращаться в случайное нажатие кнопки;
@ -75,11 +75,11 @@
- нажатие `Account` открывает `ACCOUNT_SCREEN`; - нажатие `Account` открывает `ACCOUNT_SCREEN`;
- `ACCOUNT_SCREEN` показывает 3 кнопки: - `ACCOUNT_SCREEN` показывает 3 кнопки:
- `Login (<value|not set>)` - `Login (<value|not set>)`
- `Subserver (<value|not set>)` - `Homeserver (<value|not set>)`
- `Secret (<*****|not set>)` - `Secret (<*****|not set>)`
- `Login` открывает общий экран редактирования и сохраняется в NVS; - `Login` открывает общий экран редактирования и сохраняется в NVS;
- `Subserver` открывает промежуточный экран с `USE SUBSERVER1` и `EDIT MANUALLY`; - `Homeserver` открывает промежуточный экран с `USE HOMESERVER1` и `EDIT MANUALLY`;
- `USE SUBSERVER1` возвращает стандартное значение `subserver1`; - `USE HOMESERVER1` возвращает стандартное значение `homeserver1`;
- `EDIT MANUALLY` открывает общий экран редактирования и сохраняет значение в NVS; - `EDIT MANUALLY` открывает общий экран редактирования и сохраняет значение в NVS;
- `Secret` теперь открывает меню секрета с показом секрета, ручным вводом и генерацией; - `Secret` теперь открывает меню секрета с показом секрета, ручным вводом и генерацией;
- в `SHOW SECRET` показывается прокручиваемый список всех ключей: - в `SHOW SECRET` показывается прокручиваемый список всех ключей:
@ -90,15 +90,15 @@
- `Blockchain key priv (base58)` - `Blockchain key priv (base58)`
- `Device key (base58)` - `Device key (base58)`
- `Device key priv (base58)` - `Device key priv (base58)`
- `Subserver key (base58)` - `Homeserver key (base58)`
- `Subserver key priv (base58)` - `Homeserver key priv (base58)`
- значения ключей показываются полными строками увеличенным шрифтом; - значения ключей показываются полными строками увеличенным шрифтом;
- при смене `login` сохранённый секрет сбрасывается в `not set`; - при смене `login` сохранённый секрет сбрасывается в `not set`;
- во время генерации секрета есть `CANCEL` и подтверждение остановки; - во время генерации секрета есть `CANCEL` и подтверждение остановки;
- при отмене генерации старый секрет, если он был, не должен теряться; - при отмене генерации старый секрет, если он был, не должен теряться;
- свайп вправо из внутренних экранов возвращает в `SETTINGS_MENU`; - свайп вправо из внутренних экранов возвращает в `SETTINGS_MENU`;
- свайп вправо из `ACCOUNT_SUBSERVER_SCREEN` и `ACCOUNT_SECRET_SCREEN` возвращает в `ACCOUNT_SCREEN`; - свайп вправо из `ACCOUNT_HOMESERVER_SCREEN` и `ACCOUNT_SECRET_SCREEN` возвращает в `ACCOUNT_SCREEN`;
- если во время реального свайпа палец проходит по кнопке, это не должно открывать кнопку как обычный `click`. - если во время реального свайпа палец проходит по кнопке, это не должно открывать кнопку как обычный `click`.
- Ожидаемый результат: новый скетч даёт чистый навигационный каркас и уже умеет настраивать Wi-Fi и серверные адреса на самой ESP32. - Ожидаемый результат: новый скетч даёт чистый навигационный каркас и уже умеет настраивать Wi-Fi и серверные адреса на самой ESP32.
- Дополнительно ожидается: `HOME` уже показывает реальный Solana/WS-статус сабсервера, а отсутствие пользователя в Solana заметно сразу без перехода в настройки. - Дополнительно ожидается: `HOME` уже показывает реальный Solana/WS-статус homeserver, а отсутствие пользователя в Solana заметно сразу без перехода в настройки.
- Статус: pending - Статус: pending

View File

@ -90,7 +90,7 @@ UserPdaRecordV1
| `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. | | `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. |
| `30` | `ServerProfileBlock` | Серверные данные пользователя. | | `30` | `ServerProfileBlock` | Серверные данные пользователя. |
| `40` | `AccessServersBlock` | Серверы доступа/relay. | | `40` | `AccessServersBlock` | Серверы доступа/relay. |
| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и саб-серверы. | | `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. |
| `70` | `TrustedStateBlock` | Счетчик trusted-связей. | | `70` | `TrustedStateBlock` | Счетчик trusted-связей. |
| `255` | `ReservedBlock` | Зарезервировано, пока не используется. | | `255` | `ReservedBlock` | Зарезервировано, пока не используется. |
@ -309,7 +309,7 @@ SessionRecord
| Значение | Смысл | | Значение | Смысл |
|----------|-------| |----------|-------|
| `1` | Обычная пользовательская сессия. | | `1` | Обычная пользовательская сессия. |
| `100` | Саб-сервер пользователя. | | `100` | Homeserver пользователя. |
Правила: Правила:

View File

@ -0,0 +1,134 @@
# Аудит безопасности Solana-программ SHiNE — выпуск 3 (12.06.2026)
Тематический аудит с фокусом на **полноту проверок входных аккаунтов**
(signer / owner / каноничный PDA-адрес / system-program / sysvar инструкций /
аккаунт оракула) — отвечает на вопрос «точно ли хватает всех проверок входных
аккаунтов». Код перечитан целиком после исправлений аудита №2
(`Solana-audit-2-by-Claude-11июня2026.md`):
- `shine_login_guard` (183 строки) — stateless-классификатор логинов, аккаунтами не пользуется;
- `shine_users` (1068 строк) — реестр пользователей, PDA-записи, ed25519-подписи, экономика лимитов;
- `shine_payments` (1398 строк) — очереди тикетов, выплаты из вольта, оракул Pyth.
Это ручная (не-Anchor `#[derive(Accounts)]`) реализация на `solana_program`, поэтому
каждая проверка аккаунта выполняется явно в коде handler-а. Перебраны: подмена
аккаунтов/PDA, подмена владельца, bump-seed атаки, отсутствие signer/authority,
подмена system-program и sysvar, подмена аккаунта оракула, неинициализированные/
повторно инициализируемые PDA, «лишние» аккаунты.
## Итоговый вердикт
**Проверок входных аккаунтов достаточно во всех трёх программах.** По каждому
handler присутствуют все требуемые классы проверок; грубых дыр (подмена PDA на
чужой аккаунт, отсутствие owner/signer-проверки, использование пользовательского
bump, подмена аккаунта оракула) не найдено. Все Critical/HIGH из аудитов №1 и №2
закрыты и в этом проходе подтверждены в коде. Новых эксплуатируемых пробелов в
валидации аккаунтов нет; есть несколько LOW/INFO-замечаний «by design».
## Статус прошлых находок (подтверждено в коде на 12.06.2026)
- 🔴 Critical #1 (economy-config PDA, `shine_users`) — закрыто: `validate_users_economy_config_pda` (адрес + `owner == program_id`) вызывается и в create, и в update перед чтением.
- 🔴 Critical #2 (singleton-PDA, `shine_payments`) — закрыто: `validate_singleton_state_pda` (адрес + `owner == id()`) во всех инструкциях.
- 🟠 Medium (валидация Pyth) — закрыто: пин адреса `PYTH_SOL_USD_ACCOUNT`, `owner == pyth_receiver`, `PriceUpdateV2`, `feed_id`, возраст, доверительный интервал.
- 🟡 Low (griefing на предсказуемых адресах) — закрыто: `create_pda_account` создаёт «поверх предзаполненного» в обеих программах.
- 🔴 HIGH аудита №2 (`recipient_wallet == inflow_vault` замораживает выплаты) — закрыто: запрет `recipient == inflow_vault` в `buy_ticket_by_purchase_usd` (стр. 1026), `process_manager_add_ticket` (стр. 747), `process_change_ticket_recipient` (стр. 878) + защита по умолчанию `require!(vault.key != recipient.key)` в `transfer_from_vault` (стр. 1278).
---
## Матрица проверок входных аккаунтов
### shine_users
| Инструкция | signer | owner PDA | адрес/seed PDA | system | sysvar / подпись | прочее |
|---|---|---|---|---|---|---|
| `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` |
| `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 сверен дважды |
| `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-поля сверены с прежней записью |
### shine_payments
| Инструкция | signer | owner / валидация PDA | адрес PDA | system | прочее |
|---|---|---|---|---|---|
| `init` | ✓ payer | все 4 PDA `is_uninitialized` | деривация + сверка | ✓ | `dao_wallet` из `settings`, нет лишних аккаунтов |
| `update_coef_limit` | ✓ + `signer == config.dao_wallet` | config/coef `owner == id()` | деривация + сверка | — | границы coef/limit/reward; нет лишних аккаунтов |
| `grant_manager_limits` | ✓ + `signer == config.dao_wallet` | config `owner == id()`; allowance create/read | allowance из `manager_wallet` | ✓ | `state.manager_wallet == args.manager_wallet` |
| `buy_ticket` / `_usd` / `_sol` | ✓ | config/coef/queues `owner == id()` | ticket деривация + сверка + `is_uninitialized` | ✓ | oracle (key+owner+возраст+confidence), `dao_wallet == config.dao_wallet`, `recipient != inflow_vault`, slippage |
| `manager_add_ticket` | ✓ | allowance/queues `owner == id()` | allowance из `signer`; ticket деривация + сверка + uninit | ✓ | `allowance.manager_wallet == signer`, `queue_id ∈ {1,2,3}`, `recipient != inflow_vault` |
| `step_payout` | ✓ | все singleton-PDA `owner == id()` | ticket деривация + сверка | — | `dao_wallet == config.dao_wallet`, `inflow == config.inflow_vault`, ticket `queue/index/!is_paid/recipient`, oracle |
| `change_ticket_recipient` | ✓ + `signer == ticket.recipient_wallet` | queues + ticket `owner == id()` (через `read_state`) | ticket деривация из своих `queue_id/index` + сверка | — | `!is_paid`, запрет менять «следующий к выплате», `recipient != inflow_vault` |
### shine_login_guard
Аккаунты не используются (`_accounts`); программа stateless, средствами не владеет.
Защита со стороны вызова реализована в `shine_users`: сверяется и адрес вызываемой
программы (`login_guard_program.key == SHINE_LOGIN_GUARD_PROGRAM_ID`), и `program_id`
в `get_return_data`. Подмена/подделка ответа исключены. Отдельных проверок входных
аккаунтов внутри программы не требуется.
---
## 🟡 LOW / INFO — наблюдения без прямой эксплуатации
### L1. Permissionless `init` в обеих программах
`shine_payments::init` и `shine_users::init_users_economy_config` может вызвать кто
угодно первым. Практического эксплойта нет: все значения (включая `dao_wallet` и
`DAO_AUTHORITY`) берутся из констант `settings`, а не из ввода, повторная
инициализация заблокирована проверками `is_uninitialized` / `data_is_empty`. Риск
низкий; при желании привязать init к ожидаемому деплой-кошельку. Совпадает с моделью
«первый init = деплой».
### L2. В `shine_users` нет явной проверки «лишних аккаунтов» — ✅ ИСПРАВЛЕНО (12.06.2026)
`shine_payments` в каждом handler делает `require!(account_iter.next().is_none())`.
В `shine_users` такой проверки не было — лишние аккаунты в конце списка просто
игнорировались (читается строго нужное количество через `next_account_info`). Это
безвредно (на безопасность не влияло), но для симметрии и явности добавлено.
Класс: гигиена, не уязвимость.
Закрыто: во все 4 инструкции `shine_users` (`init_users_economy_config`,
`update_users_economy_config`, `create_user_pda`, `update_user_pda`) после чтения
фиксированного набора аккаунтов добавлено `require!(it.next().is_none(),
ShineUsersError::InvalidInstruction)`. Документация — `doc/programs/shine_users.md` §3.4.
### L3. Гонка за логином (first-come) в `shine_users` — known issue
Адрес `user_pda` детерминирован из логина; после закрытия griefing-подсева остаётся
обычное состязание за регистрацию (front-run в мемпуле). On-chain решается только
commit-reveal; для текущей модели — приемлемый риск, ранее зафиксирован в аудите №2
(L2). К проверкам аккаунтов не относится.
### L4. Экономическая устойчивость вольта (дизайн, не баг)
Деньги за покупку тикетов уходят на `dao_wallet`, а выплаты `step_payout` идут из
`inflow_vault`, наполняемого регистрационными комиссиями `shine_users` (коэффициент
по умолчанию `START_COEF_PPM = 5x`). При недостаточном притоке регистраций вольт
истощается и выплаты останавливаются (без потери средств). Это свойство
экономической модели «очередь/билеты», а не дефект валидации аккаунтов — отмечено
для полноты (ранее L4 в аудите №2). Мониторить баланс вольта vs обязательств.
---
## ✅ Проверено и подтверждено как корректное (по входным аккаунтам)
- **Подмена 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**: проверка `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`).
- **system-program** сверяется с `system_program::ID` там, где идёт создание аккаунта/перевод; **sysvar инструкций** сверяется с `sysvar::instructions::id()` перед ed25519-интроспекцией.
- **Аккаунт оракула**: пин адреса `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 по хэшу — указать на чужую инструкцию нельзя.
- **Алиасинг аккаунтов**: `recipient != inflow_vault` запрещён на входе во всех точках задания получателя + `vault.key != recipient.key` в `transfer_from_vault`.
- **`inflow_vault` в `shine_users`** сверяется с PDA, выведенным из `SHINE_PAYMENTS_PROGRAM_ID` и `SHINE_PAYMENTS_INFLOW_VAULT_SEED` — комиссия не может уйти на чужой адрес.
- **Реентранси** отсутствует: CPI только в System Program и в stateless `shine_login_guard` (с проверкой возвращённого `program_id`); обратных вызовов в наши программы нет.
---
## Приоритет действий
1. **LOW** — ✅ выполнено 12.06.2026: добавлено `require!(it.next().is_none(), …)` во
все инструкции `shine_users` для симметрии с `shine_payments` (L2).
2. **INFO** — зафиксировать в эксплуатационной документации known-issue гонки за
логином (L3) и экономику вольта (L4); рассмотреть привязку `init` к ожидаемому
деплой-кошельку (L1).
Критичных и высоких находок по полноте проверок входных аккаунтов в этом проходе
нет. Единственная LOW-правка (L2) применена в рамках этого же изменения; код
`shine_users` собирается успешно (`cargo build -p shine_users`).

View File

@ -1,4 +1,4 @@
# SHiNE ESP32 Subserver UI Nav Minimal Spec # SHiNE ESP32 Homeserver UI Nav Minimal Spec
Минимальный навигационный прототип для `Waveshare ESP32-S3-Touch-AMOLED-2.16`. Минимальный навигационный прототип для `Waveshare ESP32-S3-Touch-AMOLED-2.16`.
@ -23,7 +23,7 @@
- `WIFI_SCREEN` - `WIFI_SCREEN`
- `SERVER_SCREEN` - `SERVER_SCREEN`
- `ACCOUNT_SCREEN` - `ACCOUNT_SCREEN`
- `ACCOUNT_SUBSERVER_SCREEN` - `ACCOUNT_HOMESERVER_SCREEN`
- `ACCOUNT_SECRET_SCREEN` - `ACCOUNT_SECRET_SCREEN`
- `SECRET_SHOW_SCREEN` - `SECRET_SHOW_SCREEN`
- `SECRET_GENERATE_*` - `SECRET_GENERATE_*`
@ -33,7 +33,7 @@
## HOME ## HOME
Показывает: Показывает:
- сверху слева значение сабсервера или `subserver not set`; - сверху слева значение homeserver или `homeserver not set`;
- ниже значение логина или `login not set`; - ниже значение логина или `login not set`;
- справа от строки логина индикатор статуса Solana-аккаунта: - справа от строки логина индикатор статуса Solana-аккаунта:
- зелёный — все ключи совпадают; - зелёный — все ключи совпадают;
@ -51,7 +51,7 @@
- строка `SHiNE: <server> connected/account not configured/unavailable`; - строка `SHiNE: <server> connected/account not configured/unavailable`;
- при отсутствии пользователя в Solana PDA слева снизу появляется кнопка `REGISTER ACCOUNT`; - при отсутствии пользователя в Solana PDA слева снизу появляется кнопка `REGISTER ACCOUNT`;
- снизу кнопку `SETTINGS`, уменьшенную примерно до половины ширины экрана и сдвинутую к правому краю. - снизу кнопку `SETTINGS`, уменьшенную примерно до половины ширины экрана и сдвинутую к правому краю.
- внизу на тёмной полосе подпись `SHiNE subserver (v.0.18)`. - внизу на тёмной полосе подпись `SHiNE homeserver (v.0.18)`.
Строка Wi-Fi на `HOME`: Строка Wi-Fi на `HOME`:
- `Wi-Fi (not configured) not configured` - `Wi-Fi (not configured) not configured`
@ -65,11 +65,11 @@
Фоновая логика: Фоновая логика:
- пока открыт `HOME`, экран сам обновляется примерно раз в секунду; - пока открыт `HOME`, экран сам обновляется примерно раз в секунду;
- при наличии `login + secret + subserver` и Wi-Fi устройство читает Solana `user_pda` напрямую через RPC; - при наличии `login + secret + homeserver` и Wi-Fi устройство читает Solana `user_pda` напрямую через RPC;
- сравниваются `root key`, `blockchain key`, `device key` и `subserver` session-запись типа `100`; - сравниваются `root key`, `blockchain key`, `device key` и `homeserver` session-запись типа `100`;
- для строки `SHiNE:` устройство держит отдельную WebSocket-сессию с сервером SHiNE: - для строки `SHiNE:` устройство держит отдельную WebSocket-сессию с сервером SHiNE:
- авторизация через `AuthChallenge/CreateAuthSession` или `SessionChallenge/SessionLogin`; - авторизация через `AuthChallenge/CreateAuthSession` или `SessionChallenge/SessionLogin`;
- session key = публичный `subserver key`; - session key = публичный `homeserver key`;
- подтверждение создания сессии подписывается `device key`; - подтверждение создания сессии подписывается `device key`;
- heartbeat выполняется `Ping` раз в минуту. - heartbeat выполняется `Ping` раз в минуту.
@ -164,26 +164,26 @@
- заголовок `ACCOUNT`; - заголовок `ACCOUNT`;
- статусное сообщение; - статусное сообщение;
- кнопку `Login (<value|not set>)`; - кнопку `Login (<value|not set>)`;
- кнопку `Subserver (<value|not set>)`; - кнопку `Homeserver (<value|not set>)`;
- кнопку `Secret (<*****|not set>)`. - кнопку `Secret (<*****|not set>)`.
Переходы: Переходы:
- свайп вправо -> `SETTINGS_MENU` - свайп вправо -> `SETTINGS_MENU`
- `Login` -> `TEXT_EDIT_SCREEN` - `Login` -> `TEXT_EDIT_SCREEN`
- `Subserver` -> `ACCOUNT_SUBSERVER_SCREEN` - `Homeserver` -> `ACCOUNT_HOMESERVER_SCREEN`
- `Secret` -> `ACCOUNT_SECRET_SCREEN` - `Secret` -> `ACCOUNT_SECRET_SCREEN`
## ACCOUNT_SUBSERVER_SCREEN ## ACCOUNT_HOMESERVER_SCREEN
Показывает: Показывает:
- текущий `subserver`; - текущий `homeserver`;
- рекомендацию оставить `subserver1`, если устройство одно; - рекомендацию оставить `homeserver1`, если устройство одно;
- кнопку `USE SUBSERVER1`; - кнопку `USE HOMESERVER1`;
- кнопку `EDIT MANUALLY`; - кнопку `EDIT MANUALLY`;
- кнопку `BACK`. - кнопку `BACK`.
Переходы: Переходы:
- `USE SUBSERVER1` -> сохраняет `subserver1` и возвращает в `ACCOUNT_SCREEN` - `USE HOMESERVER1` -> сохраняет `homeserver1` и возвращает в `ACCOUNT_SCREEN`
- `EDIT MANUALLY` -> `TEXT_EDIT_SCREEN` - `EDIT MANUALLY` -> `TEXT_EDIT_SCREEN`
- свайп вправо -> `ACCOUNT_SCREEN` - свайп вправо -> `ACCOUNT_SCREEN`
@ -212,8 +212,8 @@
- `Blockchain key priv (base58)`; - `Blockchain key priv (base58)`;
- `Device key (base58)`; - `Device key (base58)`;
- `Device key priv (base58)`; - `Device key priv (base58)`;
- `Subserver key (base58)`; - `Homeserver key (base58)`;
- `Subserver key priv (base58)`; - `Homeserver key priv (base58)`;
- для каждого поля показывается формула derivation; - для каждого поля показывается формула derivation;
- значения ключей показываются полными строками увеличенным шрифтом; - значения ключей показываются полными строками увеличенным шрифтом;
- кнопку `BACK`. - кнопку `BACK`.
@ -293,7 +293,7 @@
Используется `Preferences` (NVS памяти ESP32): Используется `Preferences` (NVS памяти ESP32):
- `login` - `login`
- `subserver` - `homeserver`
- `secret_set` - `secret_set`
## Детали клавиатуры ## Детали клавиатуры
@ -312,7 +312,7 @@
- `DEL` - `DEL`
- `SAVE` - `SAVE`
- `CANCEL` - `CANCEL`
- ниже рамки клавиатурного блока остаётся отдельная тёмная полоса с версией `SHiNE subserver (v.0.18)`. - ниже рамки клавиатурного блока остаётся отдельная тёмная полоса с версией `SHiNE homeserver (v.0.18)`.
## Жесты ## Жесты
@ -329,7 +329,7 @@
- `WIFI_SCREEN`: свайп вправо -> `SETTINGS_MENU` - `WIFI_SCREEN`: свайп вправо -> `SETTINGS_MENU`
- `SERVER_SCREEN`: свайп вправо -> `SETTINGS_MENU` - `SERVER_SCREEN`: свайп вправо -> `SETTINGS_MENU`
- `ACCOUNT_SCREEN`: свайп вправо -> `SETTINGS_MENU` - `ACCOUNT_SCREEN`: свайп вправо -> `SETTINGS_MENU`
- `ACCOUNT_SUBSERVER_SCREEN`: свайп вправо -> `ACCOUNT_SCREEN` - `ACCOUNT_HOMESERVER_SCREEN`: свайп вправо -> `ACCOUNT_SCREEN`
- `ACCOUNT_SECRET_SCREEN`: свайп вправо -> `ACCOUNT_SCREEN` - `ACCOUNT_SECRET_SCREEN`: свайп вправо -> `ACCOUNT_SCREEN`
- `TEXT_EDIT_SCREEN`: свайп влево/вправо -> переключение страниц клавиатуры - `TEXT_EDIT_SCREEN`: свайп влево/вправо -> переключение страниц клавиатуры
- переключение страниц клавиатуры срабатывает только если свайп начался в зоне самой клавиатуры, а не по всему экрану редактора - переключение страниц клавиатуры срабатывает только если свайп начался в зоне самой клавиатуры, а не по всему экрану редактора

View File

@ -1,8 +1,8 @@
# SHiNE ESP32 Subserver UI Spec # SHiNE ESP32 Homeserver UI Spec
## Назначение ## Назначение
Этот документ описывает актуальный UI-прототип сабсервера `SHiNE` для платы `Waveshare ESP32-S3-Touch-AMOLED-2.16`. Этот документ описывает актуальный UI-прототип homeserver `SHiNE` для платы `Waveshare ESP32-S3-Touch-AMOLED-2.16`.
Документ является источником истины для Arduino-скетча: Документ является источником истины для Arduino-скетча:
@ -37,13 +37,13 @@
## Основная идея устройства ## Основная идея устройства
Устройство работает как отдельный сабсервер: Устройство работает как отдельный homeserver:
- хранит секрет на самом устройстве; - хранит секрет на самом устройстве;
- позволяет ввести логин, секрет и имя сабсервера; - позволяет ввести логин, секрет и имя homeserver;
- показывает адрес кошелька устройства; - показывает адрес кошелька устройства;
- позволяет пополнить баланс перед регистрацией; - позволяет пополнить баланс перед регистрацией;
- после выполнения условий даёт зарегистрировать устройство как сабсервер; - после выполнения условий даёт зарегистрировать устройство как homeserver;
- после регистрации может принимать входящие запросы на вход и на подпись. - после регистрации может принимать входящие запросы на вход и на подпись.
`SD`-карта не нужна для постоянного хранения секрета в этом прототипе. `SD`-карта не нужна для постоянного хранения секрета в этом прототипе.
@ -57,7 +57,7 @@
- `Wi-Fi SSID`; - `Wi-Fi SSID`;
- `Wi-Fi password`; - `Wi-Fi password`;
- `login`; - `login`;
- `session/subserver name`; - `session/homeserver name`;
- `master secret`; - `master secret`;
- `wallet address`; - `wallet address`;
- `user pda address`; - `user pda address`;
@ -142,7 +142,7 @@
- крупный статус регистрации; - крупный статус регистрации;
- имя логина; - имя логина;
- имя сабсервера; - имя homeserver;
- короткий статус Wi-Fi; - короткий статус Wi-Fi;
- короткий статус сервера; - короткий статус сервера;
- короткий статус баланса. - короткий статус баланса.
@ -162,14 +162,14 @@
Если регистрация уже сделана: Если регистрация уже сделана:
- вместо призыва к регистрации показывается статус `Сабсервер активен`. - вместо призыва к регистрации показывается статус `Homeserver активен`.
## Экран STATUS ## Экран STATUS
Показывает сводку: Показывает сводку:
- логин; - логин;
- сабсервер; - homeserver;
- есть ли секрет; - есть ли секрет;
- зарегистрировано ли устройство; - зарегистрировано ли устройство;
- подключён ли Wi-Fi; - подключён ли Wi-Fi;
@ -256,7 +256,7 @@
Показывает: Показывает:
- логин; - логин;
- имя сабсервера; - имя homeserver;
- статус секрета; - статус секрета;
- короткий отпечаток секрета; - короткий отпечаток секрета;
- статус регистрации; - статус регистрации;
@ -266,7 +266,7 @@
- `Изменить логин` - `Изменить логин`
- `Секрет` - `Секрет`
- `Имя сабсервера` - `Имя homeserver`
- `Сгенерировать` - `Сгенерировать`
- `Очистить` - `Очистить`
- `Назад` - `Назад`
@ -394,7 +394,7 @@ QR должен быть сканируемым, а не декоративны
- `SSID` - `SSID`
- `Пароль Wi-Fi` - `Пароль Wi-Fi`
- `Логин` - `Логин`
- `Имя сабсервера` - `Имя homeserver`
- `API URL` - `API URL`
- `RPC URL` - `RPC URL`
- `WS URL` - `WS URL`
@ -432,13 +432,13 @@ QR должен быть сканируемым, а не декоративны
5. проверить или задать серверные адреса; 5. проверить или задать серверные адреса;
6. открыть `Аккаунт`; 6. открыть `Аккаунт`;
7. ввести логин; 7. ввести логин;
8. задать имя сабсервера; 8. задать имя homeserver;
9. сгенерировать секрет; 9. сгенерировать секрет;
10. открыть `Кошелёк`; 10. открыть `Кошелёк`;
11. при необходимости пополнить баланс; 11. при необходимости пополнить баланс;
12. вернуться на `HOME`; 12. вернуться на `HOME`;
13. нажать `Зарегистрировать`; 13. нажать `Зарегистрировать`;
14. после подтверждения увидеть статус `Сабсервер активен`. 14. после подтверждения увидеть статус `Homeserver активен`.
Примечание: Примечание:

View File

@ -14,7 +14,7 @@
- `hello` — базовый тест экрана (пример `01_HelloWorld`) - `hello` — базовый тест экрана (пример `01_HelloWorld`)
- `simple` — простой кастомный тест: экран + touch + запись/проигрывание + наклон (IMU) - `simple` — простой кастомный тест: экран + touch + запись/проигрывание + наклон (IMU)
- `argon2` — генерация masterSecret через Argon2id с SD-картой как памятью (тест скорости) - `argon2` — генерация masterSecret через Argon2id с SD-картой как памятью (тест скорости)
- `subserver-ui` — основной UI-прототип сабсервера SHiNE: NVS, PIN, Wi-Fi, серверы, кошелёк, QR, запросы - `homeserver-ui` — основной UI-прототип homeserver SHiNE: NVS, PIN, Wi-Fi, серверы, кошелёк, QR, запросы
- `text-test` — диагностический экран рендера текста: default font, U8g2 ASCII, U8g2 кириллица, кнопки с подписями - `text-test` — диагностический экран рендера текста: default font, U8g2 ASCII, U8g2 кириллица, кнопки с подписями
- `gfx-text-test` — тот же тест рендера текста, но уже внутри новой папки `test_sketches/` - `gfx-text-test` — тот же тест рендера текста, но уже внутри новой папки `test_sketches/`
- `gfx-layout-test` — тест геометрии и нижних рядов кнопок - `gfx-layout-test` — тест геометрии и нижних рядов кнопок
@ -22,9 +22,9 @@
- `lvgl-interaction-test` — экран на `LVGL` с большим числом кнопок и сообщением о нажатой кнопке - `lvgl-interaction-test` — экран на `LVGL` с большим числом кнопок и сообщением о нажатой кнопке
- `lvgl-touch-debug-test` — точечная диагностика touch: сырые координаты, маркер точки и большая тест-кнопка `LVGL` - `lvgl-touch-debug-test` — точечная диагностика touch: сырые координаты, маркер точки и большая тест-кнопка `LVGL`
- `lvgl-official-based-test` — наш минимальный экран, но на максимально близкой к официальному `LVGL_Widgets` инициализации - `lvgl-official-based-test` — наш минимальный экран, но на максимально близкой к официальному `LVGL_Widgets` инициализации
- `lvgl-subserver-touch-test` — гибрид: `LVGL`-интерфейс, но display/touch init и raw touch-read взяты из `shine_subserver_ui`; подтверждено на устройстве, touch работает, зелёных линий по краям нет - `lvgl-subserver-touch-test` — гибрид: `LVGL`-интерфейс, но display/touch init и raw touch-read взяты из `shine_homeserver_ui`; подтверждено на устройстве, touch работает, зелёных линий по краям нет
- `lvgl-russian-font-test` — тест кастомного `LVGL`-шрифта с кириллицей: русские кнопки, длинные подписи и статусы - `lvgl-russian-font-test` — тест кастомного `LVGL`-шрифта с кириллицей: русские кнопки, длинные подписи и статусы
- `lvgl-nav-minimal-test` — новый минимальный UI-каркас сабсервера: `HOME`, `SETTINGS_MENU`, `Wi-Fi`, `Server`, `Account`, свайпы, крупные кнопки и реальная настройка Wi-Fi с сохранением в NVS - `lvgl-nav-minimal-test` — новый минимальный UI-каркас homeserver: `HOME`, `SETTINGS_MENU`, `Wi-Fi`, `Server`, `Account`, свайпы, крупные кнопки и реальная настройка Wi-Fi с сохранением в NVS
Запуск: Запуск:
@ -32,7 +32,7 @@
- `./burn.sh audio` - `./burn.sh audio`
- `./burn.sh hello` - `./burn.sh hello`
- `./burn.sh simple` - `./burn.sh simple`
- `./burn.sh subserver-ui` - `./burn.sh homeserver-ui`
- `./burn.sh text-test` - `./burn.sh text-test`
- `./burn.sh gfx-text-test` - `./burn.sh gfx-text-test`
- `./burn.sh gfx-layout-test` - `./burn.sh gfx-layout-test`
@ -43,4 +43,4 @@
- `./burn.sh lvgl-subserver-touch-test` - `./burn.sh lvgl-subserver-touch-test`
- `./burn.sh lvgl-russian-font-test` - `./burn.sh lvgl-russian-font-test`
- `./burn.sh lvgl-nav-minimal-test` - `./burn.sh lvgl-nav-minimal-test`
- `./flash_shine_subserver_ui.sh` - автоматически находит USB-порт и заливает `shine_subserver_ui` - `./flash_shine_homeserver_ui.sh` - автоматически находит USB-порт и заливает `shine_homeserver_ui`

View File

@ -34,7 +34,7 @@ case "${MODE}" in
audio) SKETCH_DIR="${DEMO_BASE}/examples/07_ES8311" ;; audio) SKETCH_DIR="${DEMO_BASE}/examples/07_ES8311" ;;
simple) SKETCH_DIR="${ROOT_DIR}/simple_av_test" ;; simple) SKETCH_DIR="${ROOT_DIR}/simple_av_test" ;;
argon2) SKETCH_DIR="${ROOT_DIR}/argon2_sd_test" ;; argon2) SKETCH_DIR="${ROOT_DIR}/argon2_sd_test" ;;
subserver-ui) SKETCH_DIR="${ROOT_DIR}/shine_subserver_ui" ;; homeserver-ui) SKETCH_DIR="${ROOT_DIR}/shine_homeserver_ui" ;;
text-test) SKETCH_DIR="${ROOT_DIR}/text_render_test" ;; text-test) SKETCH_DIR="${ROOT_DIR}/text_render_test" ;;
gfx-text-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/gfx_text_render_test" ;; gfx-text-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/gfx_text_render_test" ;;
gfx-layout-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/gfx_button_layout_test" ;; gfx-layout-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/gfx_button_layout_test" ;;
@ -47,7 +47,7 @@ case "${MODE}" in
lvgl-nav-minimal-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/lvgl_nav_minimal_test" ;; lvgl-nav-minimal-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/lvgl_nav_minimal_test" ;;
*) *)
echo "Unknown mode: ${MODE}" >&2 echo "Unknown mode: ${MODE}" >&2
echo "Use one of: hello, widgets, audio, simple, argon2, subserver-ui, text-test, gfx-text-test, gfx-layout-test, lvgl-basic-test, lvgl-interaction-test, lvgl-touch-debug-test, lvgl-official-based-test, lvgl-subserver-touch-test, lvgl-russian-font-test, lvgl-nav-minimal-test" >&2 echo "Use one of: hello, widgets, audio, simple, argon2, homeserver-ui, text-test, gfx-text-test, gfx-layout-test, lvgl-basic-test, lvgl-interaction-test, lvgl-touch-debug-test, lvgl-official-based-test, lvgl-subserver-touch-test, lvgl-russian-font-test, lvgl-nav-minimal-test" >&2
exit 2 exit 2
;; ;;
esac esac

View File

@ -43,9 +43,9 @@ fi
if [[ -z "${PORT}" ]]; then if [[ -z "${PORT}" ]]; then
echo "Не удалось автоматически найти USB-порт ESP32." >&2 echo "Не удалось автоматически найти USB-порт ESP32." >&2
echo "Подключите плату и проверьте 'arduino-cli board list'." >&2 echo "Подключите плату и проверьте 'arduino-cli board list'." >&2
echo "Либо укажите порт вручную: PORT=/dev/ttyACM0 ./flash_shine_subserver_ui.sh" >&2 echo "Либо укажите порт вручную: PORT=/dev/ttyACM0 ./flash_shine_homeserver_ui.sh" >&2
exit 1 exit 1
fi fi
echo "== Найден порт: ${PORT}" echo "== Найден порт: ${PORT}"
PORT="${PORT}" "${ROOT_DIR}/burn.sh" subserver-ui PORT="${PORT}" "${ROOT_DIR}/burn.sh" homeserver-ui

View File

@ -97,7 +97,7 @@ enum ActionId {
ACT_VERIFY_SERVERS, ACT_VERIFY_SERVERS,
ACT_SET_TEST_SERVERS, ACT_SET_TEST_SERVERS,
ACT_EDIT_LOGIN, ACT_EDIT_LOGIN,
ACT_EDIT_SUBSERVER, ACT_EDIT_HOMESERVER,
ACT_GENERATE_SECRET, ACT_GENERATE_SECRET,
ACT_CLEAR_ACCOUNT, ACT_CLEAR_ACCOUNT,
ACT_SHOW_QR, ACT_SHOW_QR,
@ -137,7 +137,7 @@ enum EditTarget {
EDIT_SSID, EDIT_SSID,
EDIT_WIFI_PASSWORD, EDIT_WIFI_PASSWORD,
EDIT_LOGIN, EDIT_LOGIN,
EDIT_SUBSERVER, EDIT_HOMESERVER,
EDIT_API, EDIT_API,
EDIT_RPC, EDIT_RPC,
EDIT_WS, EDIT_WS,
@ -174,7 +174,7 @@ struct AppData {
String wifiSsid; String wifiSsid;
String wifiPassword; String wifiPassword;
String login; String login;
String subserverName; String homeserverName;
String secret; String secret;
String walletAddress; String walletAddress;
String userPdaAddress; String userPdaAddress;
@ -551,7 +551,7 @@ static bool canRegister() {
static String registrationSummary() { static String registrationSummary() {
if (gData.registered) { if (gData.registered) {
return "Сабсервер активен"; return "Homeserver активен";
} }
if (!gData.wifiReady) { if (!gData.wifiReady) {
return "Нужен Wi-Fi"; return "Нужен Wi-Fi";
@ -1179,7 +1179,7 @@ static bool awaitTransactionConfirmation(const String &signatureB58, String &mes
return false; return false;
} }
static bool registerSubserverOnSolana(String &messageOut) { static bool registerHomeserverOnSolana(String &messageOut) {
messageOut = ""; messageOut = "";
if (!gDerivedKeys.ready) { if (!gDerivedKeys.ready) {
if (!restoreDerivedKeysFromSecret()) { if (!restoreDerivedKeysFromSecret()) {
@ -1656,7 +1656,7 @@ static bool refreshWalletBalance(String &messageOut) {
static void seedRequests() { static void seedRequests() {
gRequests[0].type = "Вход в сессию"; gRequests[0].type = "Вход в сессию";
gRequests[0].actor = "Chrome / aidarkc"; gRequests[0].actor = "Chrome / aidarkc";
gRequests[0].details = "Клиент просит подключиться к сабсерверу и открыть сессию без ввода пароля."; gRequests[0].details = "Клиент просит подключиться к homeserverу и открыть сессию без ввода пароля.";
gRequests[0].status = "Ожидает"; gRequests[0].status = "Ожидает";
gRequests[1].type = "Подпись сообщения"; gRequests[1].type = "Подпись сообщения";
@ -1670,7 +1670,7 @@ static void loadDefaults() {
gData.wifiSsid = ""; gData.wifiSsid = "";
gData.wifiPassword = ""; gData.wifiPassword = "";
gData.login = ""; gData.login = "";
gData.subserverName = "subserver1"; gData.homeserverName = "homeserver1";
gData.secret = ""; gData.secret = "";
gData.walletAddress = ""; gData.walletAddress = "";
gData.userPdaAddress = ""; gData.userPdaAddress = "";
@ -1692,7 +1692,7 @@ static void saveData() {
gPrefs.putString("wifi_ssid", gData.wifiSsid); gPrefs.putString("wifi_ssid", gData.wifiSsid);
gPrefs.putString("wifi_pass", gData.wifiPassword); gPrefs.putString("wifi_pass", gData.wifiPassword);
gPrefs.putString("login", gData.login); gPrefs.putString("login", gData.login);
gPrefs.putString("subserver", gData.subserverName); gPrefs.putString("homeserver", gData.homeserverName);
gPrefs.putString("secret", gData.secret); gPrefs.putString("secret", gData.secret);
gPrefs.putString("wallet", gData.walletAddress); gPrefs.putString("wallet", gData.walletAddress);
gPrefs.putString("user_pda", gData.userPdaAddress); gPrefs.putString("user_pda", gData.userPdaAddress);
@ -1714,7 +1714,7 @@ static void loadData() {
gData.wifiSsid = gPrefs.getString("wifi_ssid", gData.wifiSsid); gData.wifiSsid = gPrefs.getString("wifi_ssid", gData.wifiSsid);
gData.wifiPassword = gPrefs.getString("wifi_pass", gData.wifiPassword); gData.wifiPassword = gPrefs.getString("wifi_pass", gData.wifiPassword);
gData.login = gPrefs.getString("login", gData.login); gData.login = gPrefs.getString("login", gData.login);
gData.subserverName = gPrefs.getString("subserver", gData.subserverName); gData.homeserverName = gPrefs.getString("homeserver", gData.homeserverName);
gData.secret = gPrefs.getString("secret", gData.secret); gData.secret = gPrefs.getString("secret", gData.secret);
gData.walletAddress = gPrefs.getString("wallet", gData.walletAddress); gData.walletAddress = gPrefs.getString("wallet", gData.walletAddress);
gData.userPdaAddress = gPrefs.getString("user_pda", gData.userPdaAddress); gData.userPdaAddress = gPrefs.getString("user_pda", gData.userPdaAddress);
@ -1758,8 +1758,8 @@ static void generateSecretAndWallet() {
gData.registrationSignature = ""; gData.registrationSignature = "";
gData.registered = false; gData.registered = false;
gData.online = false; gData.online = false;
if (gData.subserverName.length() == 0) { if (gData.homeserverName.length() == 0) {
gData.subserverName = "subserver1"; gData.homeserverName = "homeserver1";
} }
saveData(); saveData();
} }
@ -1815,7 +1815,7 @@ static String editTargetLabel() {
case EDIT_SSID: return "SSID"; case EDIT_SSID: return "SSID";
case EDIT_WIFI_PASSWORD: return "Пароль Wi-Fi"; case EDIT_WIFI_PASSWORD: return "Пароль Wi-Fi";
case EDIT_LOGIN: return "Логин"; case EDIT_LOGIN: return "Логин";
case EDIT_SUBSERVER: return "Имя сабсервера"; case EDIT_HOMESERVER: return "Имя homeserver";
case EDIT_API: return "API URL"; case EDIT_API: return "API URL";
case EDIT_RPC: return "RPC URL"; case EDIT_RPC: return "RPC URL";
case EDIT_WS: return "WS URL"; case EDIT_WS: return "WS URL";
@ -1846,7 +1846,7 @@ static void drawHomeScreen() {
drawPanel(20, 92, 440, 98, C_PANEL, C_BORDER, 16); drawPanel(20, 92, 440, 98, C_PANEL, C_BORDER, 16);
drawText(36, 122, registrationSummary(), canRegister() || gData.registered ? C_ACCENT : C_WARN, (const uint8_t *)FONT_HEAD); drawText(36, 122, registrationSummary(), canRegister() || gData.registered ? C_ACCENT : C_WARN, (const uint8_t *)FONT_HEAD);
drawText(36, 152, "Логин: " + (gData.login.length() ? gData.login : "не задан"), C_TEXT, (const uint8_t *)FONT_BODY); drawText(36, 152, "Логин: " + (gData.login.length() ? gData.login : "не задан"), C_TEXT, (const uint8_t *)FONT_BODY);
drawText(36, 174, "Сабсервер: " + gData.subserverName, C_MUTE, (const uint8_t *)FONT_BODY); drawText(36, 174, "Homeserver: " + gData.homeserverName, C_MUTE, (const uint8_t *)FONT_BODY);
drawPanel(20, 204, 210, 82, C_CARD, C_BORDER, 12); drawPanel(20, 204, 210, 82, C_CARD, C_BORDER, 12);
drawText(34, 232, "Wi-Fi", C_TEXT, (const uint8_t *)FONT_BODY); drawText(34, 232, "Wi-Fi", C_TEXT, (const uint8_t *)FONT_BODY);
@ -1871,7 +1871,7 @@ static void drawStatusScreen() {
drawTopBar("Статус"); drawTopBar("Статус");
drawPanel(20, 92, 440, 286, C_PANEL, C_BORDER, 16); drawPanel(20, 92, 440, 286, C_PANEL, C_BORDER, 16);
drawText(36, 122, "Логин: " + (gData.login.length() ? gData.login : "не задан"), C_TEXT); drawText(36, 122, "Логин: " + (gData.login.length() ? gData.login : "не задан"), C_TEXT);
drawText(36, 148, "Сабсервер: " + gData.subserverName, C_TEXT); drawText(36, 148, "Homeserver: " + gData.homeserverName, C_TEXT);
drawText(36, 174, "Секрет: " + boolText(gData.secretReady, "сохранён", "не задан"), gData.secretReady ? C_ACCENT : C_WARN); drawText(36, 174, "Секрет: " + boolText(gData.secretReady, "сохранён", "не задан"), gData.secretReady ? C_ACCENT : C_WARN);
drawText(36, 200, "Отпечаток: " + (gData.secretReady ? shortenValue(gData.secret) : "-"), C_MUTE, (const uint8_t *)FONT_SMALL); drawText(36, 200, "Отпечаток: " + (gData.secretReady ? shortenValue(gData.secret) : "-"), C_MUTE, (const uint8_t *)FONT_SMALL);
drawText(36, 226, "Wi-Fi: " + boolText(gData.wifiReady, "готов", "не готов"), gData.wifiReady ? C_ACCENT : C_WARN); drawText(36, 226, "Wi-Fi: " + boolText(gData.wifiReady, "готов", "не готов"), gData.wifiReady ? C_ACCENT : C_WARN);
@ -1947,13 +1947,13 @@ static void drawAccountScreen() {
drawTopBar("Аккаунт"); drawTopBar("Аккаунт");
drawPanel(20, 92, 440, 188, C_PANEL, C_BORDER, 16); drawPanel(20, 92, 440, 188, C_PANEL, C_BORDER, 16);
drawText(36, 122, "Логин: " + (gData.login.length() ? gData.login : "не задан"), C_TEXT); drawText(36, 122, "Логин: " + (gData.login.length() ? gData.login : "не задан"), C_TEXT);
drawText(36, 152, "Сабсервер: " + gData.subserverName, C_TEXT); drawText(36, 152, "Homeserver: " + gData.homeserverName, C_TEXT);
drawText(36, 182, "Секрет: " + boolText(gData.secretReady, "сохранён", "не задан"), gData.secretReady ? C_ACCENT : C_WARN); drawText(36, 182, "Секрет: " + boolText(gData.secretReady, "сохранён", "не задан"), gData.secretReady ? C_ACCENT : C_WARN);
drawText(36, 212, "Кошелёк: " + (gData.walletAddress.length() ? shortenValue(gData.walletAddress, 10, 8) : "не создан"), C_MUTE, (const uint8_t *)FONT_SMALL); drawText(36, 212, "Кошелёк: " + (gData.walletAddress.length() ? shortenValue(gData.walletAddress, 10, 8) : "не создан"), C_MUTE, (const uint8_t *)FONT_SMALL);
drawText(36, 236, "Регистрация: " + boolText(gData.registered, "выполнена", "не выполнена"), gData.registered ? C_ACCENT : C_WARN); drawText(36, 236, "Регистрация: " + boolText(gData.registered, "выполнена", "не выполнена"), gData.registered ? C_ACCENT : C_WARN);
drawText(36, 260, "PDA: " + registrationDetailsShort(), C_MUTE, (const uint8_t *)FONT_SMALL); drawText(36, 260, "PDA: " + registrationDetailsShort(), C_MUTE, (const uint8_t *)FONT_SMALL);
addButton(20, 300, 212, 48, ACT_EDIT_LOGIN, "Изменить логин"); addButton(20, 300, 212, 48, ACT_EDIT_LOGIN, "Изменить логин");
addButton(248, 300, 212, 48, ACT_EDIT_SUBSERVER, "Имя сабсервера"); addButton(248, 300, 212, 48, ACT_EDIT_HOMESERVER, "Имя homeserver");
addButton(20, 360, 212, 48, ACT_GENERATE_SECRET, "Сгенерировать", true, C_OK); addButton(20, 360, 212, 48, ACT_GENERATE_SECRET, "Сгенерировать", true, C_OK);
addButton(248, 360, 212, 48, ACT_CLEAR_ACCOUNT, "Очистить", true, C_BUTTON2); addButton(248, 360, 212, 48, ACT_CLEAR_ACCOUNT, "Очистить", true, C_BUTTON2);
addButton(20, 420, 440, 36, ACT_BACK, "Назад"); addButton(20, 420, 440, 36, ACT_BACK, "Назад");
@ -2193,9 +2193,9 @@ static void applyEditValue() {
gData.registrationSignature = ""; gData.registrationSignature = "";
gNotice = "Логин сохранён"; gNotice = "Логин сохранён";
break; break;
case EDIT_SUBSERVER: case EDIT_HOMESERVER:
gData.subserverName = value.length() ? value : "subserver1"; gData.homeserverName = value.length() ? value : "homeserver1";
gNotice = "Имя сабсервера сохранено"; gNotice = "Имя homeserver сохранено";
break; break;
case EDIT_API: case EDIT_API:
gData.apiUrl = value; gData.apiUrl = value;
@ -2351,7 +2351,7 @@ static void handleAction(ActionId action) {
} }
if (action == ACT_CONFIRM_YES) { if (action == ACT_CONFIRM_YES) {
if (gConfirmTarget == CONFIRM_REGISTER) { if (gConfirmTarget == CONFIRM_REGISTER) {
registerSubserverOnSolana(gNotice); registerHomeserverOnSolana(gNotice);
} else if (gConfirmTarget == CONFIRM_CLEAR_ACCOUNT) { } else if (gConfirmTarget == CONFIRM_CLEAR_ACCOUNT) {
gData.secret = ""; gData.secret = "";
gData.walletAddress = ""; gData.walletAddress = "";
@ -2445,7 +2445,7 @@ static void handleAction(ActionId action) {
gNeedRedraw = true; gNeedRedraw = true;
break; break;
case ACT_EDIT_LOGIN: openEdit(EDIT_LOGIN, gData.login, false); break; case ACT_EDIT_LOGIN: openEdit(EDIT_LOGIN, gData.login, false); break;
case ACT_EDIT_SUBSERVER: openEdit(EDIT_SUBSERVER, gData.subserverName, false); break; case ACT_EDIT_HOMESERVER: openEdit(EDIT_HOMESERVER, gData.homeserverName, false); break;
case ACT_GENERATE_SECRET: case ACT_GENERATE_SECRET:
generateSecretAndWallet(); generateSecretAndWallet();
gNotice = gData.secretReady ? "Секрет сгенерирован, device-кошелёк выведен из него" : "Не удалось сгенерировать секрет"; gNotice = gData.secretReady ? "Секрет сгенерирован, device-кошелёк выведен из него" : "Не удалось сгенерировать секрет";

View File

@ -2,7 +2,7 @@
Набор отдельных диагностических скетчей для `Waveshare ESP32-S3-Touch-AMOLED-2.16`. Набор отдельных диагностических скетчей для `Waveshare ESP32-S3-Touch-AMOLED-2.16`.
Скетчи в этой папке нужны для быстрой проверки конкретных гипотез без влияния на основной `shine_subserver_ui`. Скетчи в этой папке нужны для быстрой проверки конкретных гипотез без влияния на основной `shine_homeserver_ui`.
## Список ## Список
@ -12,7 +12,7 @@
- `lvgl_interaction_test/` - расширенный тест `LVGL` с 9 кнопками, touch-вводом и статусом нажатия - `lvgl_interaction_test/` - расширенный тест `LVGL` с 9 кнопками, touch-вводом и статусом нажатия
- `lvgl_touch_debug_test/` - диагностика touch: сырые координаты, точка касания и одна большая кнопка `LVGL` - `lvgl_touch_debug_test/` - диагностика touch: сырые координаты, точка касания и одна большая кнопка `LVGL`
- `lvgl_official_based_test/` - минимальный наш экран поверх максимально близкой к официальному `05_LVGL_Widgets` инициализации - `lvgl_official_based_test/` - минимальный наш экран поверх максимально близкой к официальному `05_LVGL_Widgets` инициализации
- `lvgl_subserver_touch_test/` - гибридный тест: `LVGL`-экран с инициализацией дисплея и чтением touch из `shine_subserver_ui`; подтверждён на реальном устройстве - `lvgl_subserver_touch_test/` - гибридный тест: `LVGL`-экран с инициализацией дисплея и чтением touch из `shine_homeserver_ui`; подтверждён на реальном устройстве
- `lvgl_russian_font_test/` - тест кастомного кириллического `LVGL`-шрифта с русскими кнопками, длинными строками и рабочим touch - `lvgl_russian_font_test/` - тест кастомного кириллического `LVGL`-шрифта с русскими кнопками, длинными строками и рабочим touch
- `lvgl_nav_minimal_test/` - новый минимальный навигационный каркас сабсервера на рабочем `LVGL + subserver touch`, расширенный настройкой Wi-Fi и сохранением в NVS - `lvgl_nav_minimal_test/` - новый минимальный навигационный каркас сабсервера на рабочем `LVGL + subserver touch`, расширенный настройкой Wi-Fi и сохранением в NVS

View File

@ -48,7 +48,7 @@
#define TEXT_EDIT_PANEL_Y 112 #define TEXT_EDIT_PANEL_Y 112
#define TEXT_EDIT_PANEL_W 460 #define TEXT_EDIT_PANEL_W 460
#define TEXT_EDIT_PANEL_H 330 #define TEXT_EDIT_PANEL_H 330
#define TEST_VERSION "SHiNE subserver (v.0.18)" #define TEST_VERSION "SHiNE homeserver (v.0.18)"
static const char *kShineUsersProgramId = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm"; static const char *kShineUsersProgramId = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm";
static const char *kShineUsersUserPdaSeedPrefix = "user_login="; static const char *kShineUsersUserPdaSeedPrefix = "user_login=";
@ -60,7 +60,7 @@ static const uint8_t kBlockTypeServerProfile = 30;
static const uint8_t kBlockTypeAccessServers = 40; static const uint8_t kBlockTypeAccessServers = 40;
static const uint8_t kBlockTypeSessions = 50; static const uint8_t kBlockTypeSessions = 50;
static const uint8_t kBlockTypeTrustedState = 70; static const uint8_t kBlockTypeTrustedState = 70;
static const uint8_t kSessionTypeSubserver = 100; static const uint8_t kSessionTypeHomeserver = 100;
enum Screen { enum Screen {
SCREEN_HOME, SCREEN_HOME,
@ -68,7 +68,7 @@ enum Screen {
SCREEN_WIFI, SCREEN_WIFI,
SCREEN_SERVER, SCREEN_SERVER,
SCREEN_ACCOUNT, SCREEN_ACCOUNT,
SCREEN_ACCOUNT_SUBSERVER, SCREEN_ACCOUNT_HOMESERVER,
SCREEN_ACCOUNT_SECRET, SCREEN_ACCOUNT_SECRET,
SCREEN_SECRET_SHOW, SCREEN_SECRET_SHOW,
SCREEN_SECRET_GENERATE_INFO, SCREEN_SECRET_GENERATE_INFO,
@ -99,10 +99,10 @@ enum ActionId {
ACTION_SERVER_EDIT_SOLANA, ACTION_SERVER_EDIT_SOLANA,
ACTION_SERVER_EDIT_SHINE, ACTION_SERVER_EDIT_SHINE,
ACTION_ACCOUNT_EDIT_LOGIN, ACTION_ACCOUNT_EDIT_LOGIN,
ACTION_ACCOUNT_EDIT_SUBSERVER, ACTION_ACCOUNT_EDIT_HOMESERVER,
ACTION_ACCOUNT_EDIT_SECRET, ACTION_ACCOUNT_EDIT_SECRET,
ACTION_ACCOUNT_SUBSERVER_USE_DEFAULT, ACTION_ACCOUNT_HOMESERVER_USE_DEFAULT,
ACTION_ACCOUNT_SUBSERVER_EDIT_MANUAL, ACTION_ACCOUNT_HOMESERVER_EDIT_MANUAL,
ACTION_SECRET_SHOW, ACTION_SECRET_SHOW,
ACTION_SECRET_MANUAL, ACTION_SECRET_MANUAL,
ACTION_SECRET_GENERATE, ACTION_SECRET_GENERATE,
@ -129,7 +129,7 @@ enum EditContext {
EDIT_CONTEXT_SOLANA_RPC, EDIT_CONTEXT_SOLANA_RPC,
EDIT_CONTEXT_SHINE_SERVER, EDIT_CONTEXT_SHINE_SERVER,
EDIT_CONTEXT_LOGIN, EDIT_CONTEXT_LOGIN,
EDIT_CONTEXT_SUBSERVER, EDIT_CONTEXT_HOMESERVER,
EDIT_CONTEXT_SECRET_MANUAL, EDIT_CONTEXT_SECRET_MANUAL,
EDIT_CONTEXT_SECRET_GENERATE_PASSWORD, EDIT_CONTEXT_SECRET_GENERATE_PASSWORD,
}; };
@ -216,7 +216,7 @@ static String gSolanaRpcUrl = "https://api.devnet.solana.com";
static String gShineServerUrl = "https://shineup.me"; static String gShineServerUrl = "https://shineup.me";
static String gServerStatusMessage = "Edit RPC or shine host"; static String gServerStatusMessage = "Edit RPC or shine host";
static String gLoginValue; static String gLoginValue;
static String gSubserverValue = "subserver1"; static String gHomeserverValue = "homeserver1";
static bool gSecretConfigured = false; static bool gSecretConfigured = false;
static String gSecretBase58; static String gSecretBase58;
static uint8_t gSecretBytes[32] = {}; static uint8_t gSecretBytes[32] = {};
@ -257,8 +257,8 @@ static String gBlockchainPubB58;
static String gBlockchainPrivB58; static String gBlockchainPrivB58;
static String gDevicePubB58; static String gDevicePubB58;
static String gDevicePrivB58; static String gDevicePrivB58;
static String gSubserverPubB58; static String gHomeserverPubB58;
static String gSubserverPrivB58; static String gHomeserverPrivB58;
static EditContext gEditContext = EDIT_CONTEXT_NONE; static EditContext gEditContext = EDIT_CONTEXT_NONE;
static Screen gEditReturnScreen = SCREEN_HOME; static Screen gEditReturnScreen = SCREEN_HOME;
@ -305,7 +305,7 @@ static void restoreTextareaFromEditValue();
static void refreshDerivedKeys(); static void refreshDerivedKeys();
static void clearDerivedKeys(); static void clearDerivedKeys();
static String loginDisplayValue(); static String loginDisplayValue();
static String subserverDisplayValue(); static String homeserverDisplayValue();
static String homeSecretStatus(); static String homeSecretStatus();
static String secretButtonValue(); static String secretButtonValue();
static void clearSecretValue(); static void clearSecretValue();
@ -687,13 +687,13 @@ static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &p
return hostOut.length() > 0 && portOut > 0; return hostOut.length() > 0 && portOut > 0;
} }
static String subserverKeySuffix() { static String homeserverKeySuffix() {
String name = gSubserverValue; String name = gHomeserverValue;
name.trim(); name.trim();
if (name.isEmpty()) { if (name.isEmpty()) {
name = "subserver1"; name = "homeserver1";
} }
return String("subserver.key:") + name; return String("homeserver.key:") + name;
} }
static void deriveKeyPairFromSecretSuffix(const uint8_t *secret32, const String &suffix, String &pubB58, String &privB58) { static void deriveKeyPairFromSecretSuffix(const uint8_t *secret32, const String &suffix, String &pubB58, String &privB58) {
@ -718,8 +718,8 @@ static void clearDerivedKeys() {
gBlockchainPrivB58 = ""; gBlockchainPrivB58 = "";
gDevicePubB58 = ""; gDevicePubB58 = "";
gDevicePrivB58 = ""; gDevicePrivB58 = "";
gSubserverPubB58 = ""; gHomeserverPubB58 = "";
gSubserverPrivB58 = ""; gHomeserverPrivB58 = "";
} }
static void refreshDerivedKeys() { static void refreshDerivedKeys() {
@ -730,7 +730,7 @@ static void refreshDerivedKeys() {
deriveKeyPairFromSecretSuffix(gSecretBytes, "root.key", gRootPubB58, gRootPrivB58); deriveKeyPairFromSecretSuffix(gSecretBytes, "root.key", gRootPubB58, gRootPrivB58);
deriveKeyPairFromSecretSuffix(gSecretBytes, "bch.key", gBlockchainPubB58, gBlockchainPrivB58); deriveKeyPairFromSecretSuffix(gSecretBytes, "bch.key", gBlockchainPubB58, gBlockchainPrivB58);
deriveKeyPairFromSecretSuffix(gSecretBytes, "dev.key", gDevicePubB58, gDevicePrivB58); deriveKeyPairFromSecretSuffix(gSecretBytes, "dev.key", gDevicePubB58, gDevicePrivB58);
deriveKeyPairFromSecretSuffix(gSecretBytes, subserverKeySuffix(), gSubserverPubB58, gSubserverPrivB58); deriveKeyPairFromSecretSuffix(gSecretBytes, homeserverKeySuffix(), gHomeserverPubB58, gHomeserverPrivB58);
} }
static bool deriveKeypairFromSeed32(const uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]) { static bool deriveKeypairFromSeed32(const uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]) {
@ -1302,20 +1302,20 @@ static void refreshAccountPdaStatus() {
mismatch = "blockchain key mismatch"; mismatch = "blockchain key mismatch";
} else if (memcmp(devicePub, pdaState.deviceKey32, 32) != 0) { } else if (memcmp(devicePub, pdaState.deviceKey32, 32) != 0) {
mismatch = "device key mismatch"; mismatch = "device key mismatch";
} else if (gSubserverValue.isEmpty()) { } else if (gHomeserverValue.isEmpty()) {
mismatch = "subserver not set"; mismatch = "homeserver not set";
} else { } else {
bool foundSession = false; bool foundSession = false;
bool sessionMismatch = false; bool sessionMismatch = false;
for (const auto &session : pdaState.sessions) { for (const auto &session : pdaState.sessions) {
if (session.sessionType == kSessionTypeSubserver && session.sessionName == gSubserverValue) { if (session.sessionType == kSessionTypeHomeserver && session.sessionName == gHomeserverValue) {
foundSession = true; foundSession = true;
if (gSubserverPubB58.isEmpty()) { if (gHomeserverPubB58.isEmpty()) {
sessionMismatch = true; sessionMismatch = true;
} else { } else {
uint8_t subserverPub[32] = {}; uint8_t homeserverPub[32] = {};
if (!base58ToFixed32(gSubserverPubB58, subserverPub) if (!base58ToFixed32(gHomeserverPubB58, homeserverPub)
|| memcmp(subserverPub, session.sessionPubKey32, 32) != 0) { || memcmp(homeserverPub, session.sessionPubKey32, 32) != 0) {
sessionMismatch = true; sessionMismatch = true;
} }
} }
@ -1323,9 +1323,9 @@ static void refreshAccountPdaStatus() {
} }
} }
if (!foundSession) { if (!foundSession) {
mismatch = "subserver not in PDA"; mismatch = "homeserver not in PDA";
} else if (sessionMismatch) { } else if (sessionMismatch) {
mismatch = "subserver key mismatch"; mismatch = "homeserver key mismatch";
} }
} }
@ -1565,7 +1565,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) {
errorOut = "Wi-Fi disconnected"; errorOut = "Wi-Fi disconnected";
return false; return false;
} }
if (gLoginValue.isEmpty() || !gSecretConfigured || gSubserverValue.isEmpty()) { if (gLoginValue.isEmpty() || !gSecretConfigured || gHomeserverValue.isEmpty()) {
errorOut = "account not configured"; errorOut = "account not configured";
return false; return false;
} }
@ -1605,7 +1605,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) {
uint8_t subPub[32] = {}; uint8_t subPub[32] = {};
uint8_t subSec[64] = {}; uint8_t subSec[64] = {};
if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec) if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)
|| !deriveSeedKeypairFromBase58(gSubserverPrivB58, subSeed, subPub, subSec)) { || !deriveSeedKeypairFromBase58(gHomeserverPrivB58, subSeed, subPub, subSec)) {
errorOut = "local key derive failed"; errorOut = "local key derive failed";
return false; return false;
} }
@ -1634,7 +1634,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) {
+ "\",\"sessionKey\":\"" + jsonEscape(sessionKey) + "\",\"sessionKey\":\"" + jsonEscape(sessionKey)
+ "\",\"timeMs\":" + String((unsigned long long)timeMs) + "\",\"timeMs\":" + String((unsigned long long)timeMs)
+ ",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64)) + ",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64))
+ "\",\"clientInfo\":\"ESP32 subserver\"}"; + "\",\"clientInfo\":\"ESP32 homeserver\"}";
String loginResp; String loginResp;
if (shineWsRequest(gShineWs, "SessionLogin", loginReq, loginResp)) { if (shineWsRequest(gShineWs, "SessionLogin", loginReq, loginResp)) {
if (jsonInt64Field(loginResp, "status", statusCode) && statusCode == 200) { if (jsonInt64Field(loginResp, "status", statusCode) && statusCode == 200) {
@ -1691,7 +1691,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) {
+ ",\"authNonce\":\"" + jsonEscape(authNonce) + ",\"authNonce\":\"" + jsonEscape(authNonce)
+ "\",\"deviceKey\":\"" + jsonEscape(bytesToBase64String(devicePub, 32)) + "\",\"deviceKey\":\"" + jsonEscape(bytesToBase64String(devicePub, 32))
+ "\",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64)) + "\",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64))
+ "\",\"clientInfo\":\"ESP32 subserver\"}"; + "\",\"clientInfo\":\"ESP32 homeserver\"}";
String createResp; String createResp;
if (!shineWsRequest(gShineWs, "CreateAuthSession", createReq, createResp)) { if (!shineWsRequest(gShineWs, "CreateAuthSession", createReq, createResp)) {
errorOut = "CreateAuthSession failed"; errorOut = "CreateAuthSession failed";
@ -1710,7 +1710,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) {
static void manageShineConnection() { static void manageShineConnection() {
String serverLabel = gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl; String serverLabel = gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl;
if (gLoginValue.isEmpty() || !gSecretConfigured || gSubserverValue.isEmpty()) { if (gLoginValue.isEmpty() || !gSecretConfigured || gHomeserverValue.isEmpty()) {
gShineStatusLine = String("SHiNE: ") + serverLabel + " account not configured"; gShineStatusLine = String("SHiNE: ") + serverLabel + " account not configured";
clearShineSessionState(false); clearShineSessionState(false);
return; return;
@ -1802,7 +1802,7 @@ static void loadPrefs() {
gSolanaRpcUrl = gPrefs.getString("solana_rpc", "https://api.devnet.solana.com"); gSolanaRpcUrl = gPrefs.getString("solana_rpc", "https://api.devnet.solana.com");
gShineServerUrl = gPrefs.getString("shine_server", "https://shineup.me"); gShineServerUrl = gPrefs.getString("shine_server", "https://shineup.me");
gLoginValue = gPrefs.getString("login", ""); gLoginValue = gPrefs.getString("login", "");
gSubserverValue = gPrefs.getString("subserver", "subserver1"); gHomeserverValue = gPrefs.getString("homeserver", "homeserver1");
gSecretConfigured = gPrefs.getBool("secret_set", false); gSecretConfigured = gPrefs.getBool("secret_set", false);
gSecretBase58 = gPrefs.getString("secret_b58", ""); gSecretBase58 = gPrefs.getString("secret_b58", "");
if (gSecretConfigured && gPrefs.getBytesLength("secret_bytes") == 32) { if (gSecretConfigured && gPrefs.getBytesLength("secret_bytes") == 32) {
@ -1850,7 +1850,7 @@ static void saveServerPrefs() {
static void saveAccountPrefs() { static void saveAccountPrefs() {
gPrefs.putString("login", gLoginValue); gPrefs.putString("login", gLoginValue);
gPrefs.putString("subserver", gSubserverValue); gPrefs.putString("homeserver", gHomeserverValue);
gPrefs.putBool("secret_set", gSecretConfigured); gPrefs.putBool("secret_set", gSecretConfigured);
gPrefs.putString("secret_b58", gSecretBase58); gPrefs.putString("secret_b58", gSecretBase58);
if (gSecretConfigured) { if (gSecretConfigured) {
@ -1925,8 +1925,8 @@ static String loginDisplayValue() {
return gLoginValue.isEmpty() ? "login not set" : gLoginValue; return gLoginValue.isEmpty() ? "login not set" : gLoginValue;
} }
static String subserverDisplayValue() { static String homeserverDisplayValue() {
return gSubserverValue.isEmpty() ? "subserver not set" : gSubserverValue; return gHomeserverValue.isEmpty() ? "homeserver not set" : gHomeserverValue;
} }
static String homeSecretStatus() { static String homeSecretStatus() {
@ -2234,13 +2234,13 @@ static void applyEditorValue() {
return; return;
} }
if (gEditContext == EDIT_CONTEXT_SUBSERVER) { if (gEditContext == EDIT_CONTEXT_HOMESERVER) {
value.trim(); value.trim();
gSubserverValue = value; gHomeserverValue = value;
refreshDerivedKeys(); refreshDerivedKeys();
saveAccountPrefs(); saveAccountPrefs();
markAccountStateDirty(); markAccountStateDirty();
gAccountStatusMessage = gSubserverValue.isEmpty() ? "Subserver cleared" : "Subserver saved"; gAccountStatusMessage = gHomeserverValue.isEmpty() ? "Homeserver cleared" : "Homeserver saved";
showScreen(SCREEN_ACCOUNT); showScreen(SCREEN_ACCOUNT);
return; return;
} }
@ -2465,8 +2465,8 @@ static void actionButtonCb(lv_event_t *event) {
gLoginValue, gLoginValue,
false); false);
break; break;
case ACTION_ACCOUNT_EDIT_SUBSERVER: case ACTION_ACCOUNT_EDIT_HOMESERVER:
showScreen(SCREEN_ACCOUNT_SUBSERVER); showScreen(SCREEN_ACCOUNT_HOMESERVER);
break; break;
case ACTION_ACCOUNT_EDIT_SECRET: case ACTION_ACCOUNT_EDIT_SECRET:
showScreen(SCREEN_ACCOUNT_SECRET); showScreen(SCREEN_ACCOUNT_SECRET);
@ -2506,20 +2506,20 @@ static void actionButtonCb(lv_event_t *event) {
case ACTION_SECRET_GENERATE_CANCEL_NO: case ACTION_SECRET_GENERATE_CANCEL_NO:
showScreen(SCREEN_SECRET_GENERATE_RUNNING); showScreen(SCREEN_SECRET_GENERATE_RUNNING);
break; break;
case ACTION_ACCOUNT_SUBSERVER_USE_DEFAULT: case ACTION_ACCOUNT_HOMESERVER_USE_DEFAULT:
gSubserverValue = "subserver1"; gHomeserverValue = "homeserver1";
refreshDerivedKeys(); refreshDerivedKeys();
saveAccountPrefs(); saveAccountPrefs();
markAccountStateDirty(); markAccountStateDirty();
gAccountStatusMessage = "Subserver set to subserver1"; gAccountStatusMessage = "Homeserver set to homeserver1";
showScreen(SCREEN_ACCOUNT); showScreen(SCREEN_ACCOUNT);
break; break;
case ACTION_ACCOUNT_SUBSERVER_EDIT_MANUAL: case ACTION_ACCOUNT_HOMESERVER_EDIT_MANUAL:
openEditor(EDIT_CONTEXT_SUBSERVER, openEditor(EDIT_CONTEXT_HOMESERVER,
SCREEN_ACCOUNT, SCREEN_ACCOUNT,
"EDIT SUBSERVER", "EDIT HOMESERVER",
"", "",
gSubserverValue, gHomeserverValue,
false); false);
break; break;
case ACTION_BACK_SECRET_MENU: case ACTION_BACK_SECRET_MENU:
@ -2616,17 +2616,17 @@ static void makeVersionTag() {
static void drawHome() { static void drawHome() {
setRootStyle(); setRootStyle();
lv_obj_t *subserver = lv_label_create(gRoot); lv_obj_t *homeserver = lv_label_create(gRoot);
lv_label_set_text(subserver, subserverDisplayValue().c_str()); lv_label_set_text(homeserver, homeserverDisplayValue().c_str());
lv_obj_set_style_text_font(subserver, &lv_font_montserrat_18, 0); lv_obj_set_style_text_font(homeserver, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(subserver, lv_color_hex(0xFFFFFF), 0); lv_obj_set_style_text_color(homeserver, lv_color_hex(0xFFFFFF), 0);
lv_obj_align(subserver, LV_ALIGN_TOP_LEFT, 24, 18); lv_obj_align(homeserver, LV_ALIGN_TOP_LEFT, 24, 18);
lv_obj_t *login = lv_label_create(gRoot); lv_obj_t *login = lv_label_create(gRoot);
lv_label_set_text(login, loginDisplayValue().c_str()); lv_label_set_text(login, loginDisplayValue().c_str());
lv_obj_set_style_text_font(login, &lv_font_montserrat_22, 0); lv_obj_set_style_text_font(login, &lv_font_montserrat_22, 0);
lv_obj_set_style_text_color(login, lv_color_hex(0xD5DEE7), 0); lv_obj_set_style_text_color(login, lv_color_hex(0xD5DEE7), 0);
lv_obj_align_to(login, subserver, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 6); lv_obj_align_to(login, homeserver, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 6);
lv_obj_t *accountDot = lv_obj_create(gRoot); lv_obj_t *accountDot = lv_obj_create(gRoot);
lv_obj_set_size(accountDot, 14, 14); lv_obj_set_size(accountDot, 14, 14);
@ -2793,23 +2793,23 @@ static void drawAccountScreen() {
showMessageAt(gAccountStatusMessage, 56); showMessageAt(gAccountStatusMessage, 56);
String loginButton = String("Login (") + (gLoginValue.isEmpty() ? "not set" : gLoginValue) + ")"; String loginButton = String("Login (") + (gLoginValue.isEmpty() ? "not set" : gLoginValue) + ")";
String subserverButton = String("Subserver (") + (gSubserverValue.isEmpty() ? "not set" : gSubserverValue) + ")"; String homeserverButton = String("Homeserver (") + (gHomeserverValue.isEmpty() ? "not set" : gHomeserverValue) + ")";
String secretButton = String("Secret (") + secretButtonValue() + ")"; String secretButton = String("Secret (") + secretButtonValue() + ")";
makeButton(loginButton.c_str(), 22, 118, 436, 84, 0x355C7D, ACTION_ACCOUNT_EDIT_LOGIN, &lv_font_montserrat_20); makeButton(loginButton.c_str(), 22, 118, 436, 84, 0x355C7D, ACTION_ACCOUNT_EDIT_LOGIN, &lv_font_montserrat_20);
makeButton(subserverButton.c_str(), 22, 222, 436, 84, 0x355C7D, ACTION_ACCOUNT_EDIT_SUBSERVER, &lv_font_montserrat_20); makeButton(homeserverButton.c_str(), 22, 222, 436, 84, 0x355C7D, ACTION_ACCOUNT_EDIT_HOMESERVER, &lv_font_montserrat_20);
makeButton(secretButton.c_str(), 22, 326, 436, 84, 0x355C7D, ACTION_ACCOUNT_EDIT_SECRET, &lv_font_montserrat_20); makeButton(secretButton.c_str(), 22, 326, 436, 84, 0x355C7D, ACTION_ACCOUNT_EDIT_SECRET, &lv_font_montserrat_20);
makeBody("Swipe right to return to Settings.", 420, 420); makeBody("Swipe right to return to Settings.", 420, 420);
makeVersionTag(); makeVersionTag();
} }
static void drawAccountSubserverScreen() { static void drawAccountHomeserverScreen() {
setRootStyle(); setRootStyle();
makeTitle("SUBSERVER", 18, &lv_font_montserrat_24); makeTitle("HOMESERVER", 18, &lv_font_montserrat_24);
showMessageAt(String("Current: ") + subserverDisplayValue(), 56); showMessageAt(String("Current: ") + homeserverDisplayValue(), 56);
makeBody("If you only use one subserver, keep the default name subserver1.", 98, 420); makeBody("If you only use one homeserver, keep the default name homeserver1.", 98, 420);
makeButton("USE SUBSERVER1", 22, 202, 436, 84, 0x2A9D8F, ACTION_ACCOUNT_SUBSERVER_USE_DEFAULT, &lv_font_montserrat_22); makeButton("USE HOMESERVER1", 22, 202, 436, 84, 0x2A9D8F, ACTION_ACCOUNT_HOMESERVER_USE_DEFAULT, &lv_font_montserrat_22);
makeButton("EDIT MANUALLY", 22, 306, 436, 84, 0x355C7D, ACTION_ACCOUNT_SUBSERVER_EDIT_MANUAL, &lv_font_montserrat_22); makeButton("EDIT MANUALLY", 22, 306, 436, 84, 0x355C7D, ACTION_ACCOUNT_HOMESERVER_EDIT_MANUAL, &lv_font_montserrat_22);
makeButton("BACK", 140, 402, 200, 54, 0x5A6570, ACTION_BACK_ACCOUNT, &lv_font_montserrat_20); makeButton("BACK", 140, 402, 200, 54, 0x5A6570, ACTION_BACK_ACCOUNT, &lv_font_montserrat_20);
makeVersionTag(); makeVersionTag();
} }
@ -2876,8 +2876,8 @@ static void drawSecretShowScreen() {
addKeyBlock("Blockchain key priv (base58)", "sha256(base64(secret)|bch.key)", gBlockchainPrivB58); addKeyBlock("Blockchain key priv (base58)", "sha256(base64(secret)|bch.key)", gBlockchainPrivB58);
addKeyBlock("Device key (base58)", "pub from sha256(base64(secret)|dev.key)", gDevicePubB58); addKeyBlock("Device key (base58)", "pub from sha256(base64(secret)|dev.key)", gDevicePubB58);
addKeyBlock("Device key priv (base58)", "sha256(base64(secret)|dev.key)", gDevicePrivB58); addKeyBlock("Device key priv (base58)", "sha256(base64(secret)|dev.key)", gDevicePrivB58);
addKeyBlock("Subserver key (base58)", String("pub from sha256(base64(secret)|") + subserverKeySuffix() + ")", gSubserverPubB58); addKeyBlock("Homeserver key (base58)", String("pub from sha256(base64(secret)|") + homeserverKeySuffix() + ")", gHomeserverPubB58);
addKeyBlock("Subserver key priv (base58)", String("sha256(base64(secret)|") + subserverKeySuffix() + ")", gSubserverPrivB58); addKeyBlock("Homeserver key priv (base58)", String("sha256(base64(secret)|") + homeserverKeySuffix() + ")", gHomeserverPrivB58);
} else { } else {
showMessageAt("Secret not set", 96); showMessageAt("Secret not set", 96);
} }
@ -3084,8 +3084,8 @@ static void rebuildScreen() {
case SCREEN_ACCOUNT: case SCREEN_ACCOUNT:
drawAccountScreen(); drawAccountScreen();
break; break;
case SCREEN_ACCOUNT_SUBSERVER: case SCREEN_ACCOUNT_HOMESERVER:
drawAccountSubserverScreen(); drawAccountHomeserverScreen();
break; break;
case SCREEN_ACCOUNT_SECRET: case SCREEN_ACCOUNT_SECRET:
drawAccountSecretScreen(); drawAccountSecretScreen();
@ -3219,7 +3219,7 @@ static void handleSwipe(SwipeDirection swipe) {
case SCREEN_ACCOUNT: case SCREEN_ACCOUNT:
handleAccountSwipe(swipe); handleAccountSwipe(swipe);
break; break;
case SCREEN_ACCOUNT_SUBSERVER: case SCREEN_ACCOUNT_HOMESERVER:
case SCREEN_ACCOUNT_SECRET: case SCREEN_ACCOUNT_SECRET:
handleAccountSubscreenSwipe(swipe); handleAccountSubscreenSwipe(swipe);
break; break;

View File

@ -4,7 +4,7 @@
#include <Arduino_GFX_Library.h> #include <Arduino_GFX_Library.h>
#include <TouchDrvCSTXXX.hpp> #include <TouchDrvCSTXXX.hpp>
// Подтверждено на устройстве: LVGL-рендер работает вместе с touch-путём из shine_subserver_ui. // Подтверждено на устройстве: LVGL-рендер работает вместе с touch-путём из shine_homeserver_ui.
#define PIN_LCD_CS 12 #define PIN_LCD_CS 12
#define PIN_LCD_SCLK 38 #define PIN_LCD_SCLK 38
@ -146,7 +146,7 @@ static void createUi() {
lv_obj_align(gVersionLabel, LV_ALIGN_TOP_MID, 0, 12); lv_obj_align(gVersionLabel, LV_ALIGN_TOP_MID, 0, 12);
lv_obj_t *subtitle = lv_label_create(screen); lv_obj_t *subtitle = lv_label_create(screen);
lv_label_set_text(subtitle, "Touch path comes from shine_subserver_ui. Tap buttons and watch status."); lv_label_set_text(subtitle, "Touch path comes from shine_homeserver_ui. Tap buttons and watch status.");
lv_obj_set_width(subtitle, 436); lv_obj_set_width(subtitle, 436);
lv_label_set_long_mode(subtitle, LV_LABEL_LONG_WRAP); lv_label_set_long_mode(subtitle, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(subtitle, &lv_font_montserrat_14, 0); lv_obj_set_style_text_font(subtitle, &lv_font_montserrat_14, 0);

View File

@ -1,2 +1,2 @@
client.version=1.2.161 client.version=1.2.162
server.version=1.2.150 server.version=1.2.151

View File

@ -176,6 +176,11 @@ export function render({ navigate }) {
const prevPassword = String(state.registrationDraft.password || ''); const prevPassword = String(state.registrationDraft.password || '');
const nextLogin = String(loginInput.value.trim()); const nextLogin = String(loginInput.value.trim());
const nextPassword = String(passwordInput.value || ''); const nextPassword = String(passwordInput.value || '');
if (nextPassword.length === 0) {
formError.textContent = 'Пустой пароль запрещён. Введите непустой пароль для регистрации.';
formError.style.display = '';
return;
}
const credsChanged = prevLogin !== nextLogin || prevPassword !== nextPassword; const credsChanged = prevLogin !== nextLogin || prevPassword !== nextPassword;
state.registrationDraft.login = nextLogin; state.registrationDraft.login = nextLogin;

View File

@ -1,7 +1,6 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { state } from '../state.js'; import { state } from '../state.js';
import { import {
deriveWalletFromPassword,
formatSol, formatSol,
getBalanceSol, getBalanceSol,
getTopupSiteUrl, getTopupSiteUrl,
@ -10,6 +9,21 @@ import {
export const pageMeta = { id: 'topup-view', title: 'Пополнение счета', showAppChrome: false }; export const pageMeta = { id: 'topup-view', title: 'Пополнение счета', showAppChrome: false };
// Канонический Solana-адрес пополнения = публичный device-ключ из сгенерированного набора ключей.
// Тот же путь, что в registration-payment-view (deriveUserWalletAddress); не выводим адрес
// напрямую из пароля, иначе он расходится с device-ключом регистрации.
async function deviceWalletAddressFromBundle() {
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
if (!keyBundle || !keyBundle.devicePair) {
throw new Error('Ключи ещё не сгенерированы. Вернитесь на экран регистрации.');
}
const raw = atob(keyBundle.devicePair.publicKeyB64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);
const { PublicKey } = await import('https://esm.sh/@solana/web3.js@1.98.4');
return new PublicKey(bytes).toBase58();
}
export function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack';
@ -55,7 +69,7 @@ export function render({ navigate }) {
</div> </div>
<a class="link-card" id="topup-site-link" href="${getTopupSiteUrl(state.registrationPayment.walletAddress || '')}" target="_blank" rel="noreferrer">Открыть сайт пополнения</a> <a class="link-card" id="topup-site-link" href="${getTopupSiteUrl(state.registrationPayment.walletAddress || '')}" target="_blank" rel="noreferrer">Открыть сайт пополнения</a>
<div class="card stack" style="padding:12px; max-width:320px;"> <div class="card stack" style="padding:12px; max-width:320px;">
<div class="field-label" style="margin-bottom:6px;">Кошелёк для пополнения (wallet.key)</div> <div class="field-label" style="margin-bottom:6px;">Кошелёк для пополнения (device key = Solana wallet)</div>
</div> </div>
`; `;
card.children[3].append(walletRow); card.children[3].append(walletRow);
@ -103,9 +117,9 @@ export function render({ navigate }) {
(async () => { (async () => {
try { try {
if (!walletValue.value) { if (!walletValue.value) {
const wallet = await deriveWalletFromPassword(String(state.registrationDraft.password ?? '')); const address = await deviceWalletAddressFromBundle();
state.registrationPayment.walletAddress = wallet.address; state.registrationPayment.walletAddress = address;
walletValue.value = wallet.address; walletValue.value = address;
} }
const topupSiteLink = card.querySelector('#topup-site-link'); const topupSiteLink = card.querySelector('#topup-site-link');
if (topupSiteLink instanceof HTMLAnchorElement) { if (topupSiteLink instanceof HTMLAnchorElement) {

View File

@ -116,12 +116,6 @@ export async function sha256Text(text) {
return sha256Bytes(utf8Bytes(text)); return sha256Bytes(utf8Bytes(text));
} }
export async function derivePasswordSeed(password, suffix) {
const base = await sha256Text(password || '');
const concat = `${bytesToBase64(base)}${suffix}`;
return sha256Text(concat);
}
function normalizeLoginForKdf(login) { function normalizeLoginForKdf(login) {
return String(login || '').trim().toLowerCase(); return String(login || '').trim().toLowerCase();
} }
@ -134,21 +128,6 @@ async function makeArgon2Salt(login, suffix) {
return digest.slice(0, 16); return digest.slice(0, 16);
} }
async function derivePasswordSeedArgon2id({ login, password, suffix }) {
const normalizedLogin = normalizeLoginForKdf(login);
const normalizedPassword = String(password ?? '');
const normalizedSuffix = String(suffix || '').trim();
const salt = await makeArgon2Salt(normalizedLogin, normalizedSuffix);
const passBytes = utf8Bytes(`${normalizedLogin}\n${normalizedPassword}`);
const out = await argon2idAsync(passBytes, salt, {
t: 2,
m: 65536,
p: 1,
dkLen: 32,
});
return new Uint8Array(out);
}
async function deriveMasterSecretArgon2id({ login, password, onProgress }) { async function deriveMasterSecretArgon2id({ login, password, onProgress }) {
const normalizedLogin = normalizeLoginForKdf(login); const normalizedLogin = normalizeLoginForKdf(login);
const normalizedPassword = String(password ?? ''); const normalizedPassword = String(password ?? '');
@ -177,38 +156,13 @@ function ed25519Pkcs8FromSeed(seed32) {
return out; return out;
} }
export async function deriveEd25519FromPassword(password, suffix, options = {}) {
const normalizedPassword = String(password ?? '');
const normalizedLogin = String(options?.login ?? '');
const useLegacyEmptyPassword = normalizedPassword.length === 0;
const seed = useLegacyEmptyPassword
? await derivePasswordSeed(normalizedPassword, suffix)
: await derivePasswordSeedArgon2id({
login: normalizedLogin,
password: normalizedPassword,
suffix,
});
const pkcs8 = ed25519Pkcs8FromSeed(seed);
const subtle = getSubtleApi();
const privateKey = await subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
const jwk = await subtle.exportKey('jwk', privateKey);
if (!jwk.x) throw new Error('Не удалось получить публичный ключ Ed25519');
return {
privateKey,
publicKeyB64: bytesToBase64(base64ToBytes(base64UrlToBase64(jwk.x))),
privatePkcs8B64: bytesToBase64(pkcs8),
};
}
export async function deriveMasterSecretFromPassword(password, options = {}) { export async function deriveMasterSecretFromPassword(password, options = {}) {
const normalizedPassword = String(password ?? ''); const normalizedPassword = String(password ?? '');
const normalizedLogin = String(options?.login ?? ''); const normalizedLogin = String(options?.login ?? '');
const onProgress = typeof options?.onProgress === 'function' ? options.onProgress : undefined; const onProgress = typeof options?.onProgress === 'function' ? options.onProgress : undefined;
if (normalizedPassword.length === 0) { if (normalizedPassword.length === 0) {
const legacy = await derivePasswordSeed(normalizedPassword, 'master.secret'); // Пустой пароль запрещён: упрощённый легаси-путь убран, регистрация/вход требуют непустой пароль.
if (onProgress) onProgress(1); throw new Error('Пустой пароль запрещён: регистрация и вход требуют непустой пароль.');
return legacy;
} }
return deriveMasterSecretArgon2id({ return deriveMasterSecretArgon2id({
login: normalizedLogin, login: normalizedLogin,

View File

@ -25,7 +25,7 @@ const BLOCK_TYPE_SESSIONS = 50;
const BLOCK_TYPE_TRUSTED_STATE = 70; const BLOCK_TYPE_TRUSTED_STATE = 70;
const SESSIONS_MODE_MIXED = 1; const SESSIONS_MODE_MIXED = 1;
const SESSION_TYPE_USER = 1; const SESSION_TYPE_USER = 1;
const SESSION_TYPE_SUBSERVER = 100; const SESSION_TYPE_HOMESERVER = 100;
let solanaLibPromise = null; let solanaLibPromise = null;
function loadSolanaLib() { function loadSolanaLib() {

View File

@ -1,4 +1,3 @@
import { deriveEd25519FromPassword } from './crypto-utils.js';
import { extractDeviceKey32FromStoredValue } from './device-key-utils.js'; import { extractDeviceKey32FromStoredValue } from './device-key-utils.js';
import { loadEncryptedUserSecrets } from './key-vault.js'; import { loadEncryptedUserSecrets } from './key-vault.js';
import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js'; import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js';
@ -68,17 +67,6 @@ async function keypairFromPkcs8(pkcs8B64) {
return solana.Keypair.fromSeed(seed32); return solana.Keypair.fromSeed(seed32);
} }
export async function deriveWalletFromPassword(password) {
const keyBundle = await deriveEd25519FromPassword(String(password ?? ''), 'dev.key');
const keypair = await keypairFromPkcs8(keyBundle.privatePkcs8B64);
return {
address: keypair.publicKey.toBase58(),
keypair,
devicePublicKeyB64: keyBundle.publicKeyB64,
devicePrivatePkcs8B64: keyBundle.privatePkcs8B64,
};
}
export async function createRandomSolanaWallet() { export async function createRandomSolanaWallet() {
const solana = await loadSolanaLib(); const solana = await loadSolanaLib();
const keypair = solana.Keypair.generate(); const keypair = solana.Keypair.generate();

View File

@ -90,7 +90,7 @@ UserPdaRecordV1
| `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. | | `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. |
| `30` | `ServerProfileBlock` | Серверные данные пользователя. | | `30` | `ServerProfileBlock` | Серверные данные пользователя. |
| `40` | `AccessServersBlock` | Серверы доступа/relay. | | `40` | `AccessServersBlock` | Серверы доступа/relay. |
| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и саб-серверы. | | `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. |
| `70` | `TrustedStateBlock` | Счетчик trusted-связей. | | `70` | `TrustedStateBlock` | Счетчик trusted-связей. |
| `255` | `ReservedBlock` | Зарезервировано, пока не используется. | | `255` | `ReservedBlock` | Зарезервировано, пока не используется. |
@ -309,7 +309,7 @@ SessionRecord
| Значение | Смысл | | Значение | Смысл |
|----------|-------| |----------|-------|
| `1` | Обычная пользовательская сессия. | | `1` | Обычная пользовательская сессия. |
| `100` | Саб-сервер пользователя. | | `100` | Homeserver пользователя. |
Правила: Правила:

View File

@ -91,6 +91,18 @@ system-переводом. Если бы создание шло строго ч
Проверки повторной инициализации (`owner == System Program` и пустые данные) остаются и Проверки повторной инициализации (`owner == System Program` и пустые данные) остаются и
не зависят от баланса аккаунта. не зависят от баланса аккаунта.
### 3.4. Строгий список аккаунтов (нет «лишних» аккаунтов)
Все инструкции `shine_users` читают строго фиксированный набор аккаунтов и после этого
явно требуют, чтобы в переданном списке больше ничего не было
(`require!(it.next().is_none(), InvalidInstruction)`). Если вызывающий добавит лишние
аккаунты в хвост, инструкция завершится ошибкой `InvalidInstruction (1)`.
Это не закрывает отдельной уязвимости (каждый используемый аккаунт и так строго
валидируется по signer/owner/адресу PDA), а является defense-in-depth и приводит поведение
к единому виду с `shine_payments`, где такая же проверка стоит во всех инструкциях.
Списки аккаунтов в разделах ниже надо считать исчерпывающими и точными по количеству.
## 4. Состояния программы ## 4. Состояния программы
### 4.1. `UsersEconomyConfigState` ### 4.1. `UsersEconomyConfigState`

View File

@ -40,7 +40,7 @@ const BLOCKCHAIN_TYPE_MAIN_USER: u8 = 1;
const SESSIONS_MODE_MIXED: u8 = 1; const SESSIONS_MODE_MIXED: u8 = 1;
const SESSIONS_MODE_PDA_ONLY: u8 = 10; const SESSIONS_MODE_PDA_ONLY: u8 = 10;
const SESSION_TYPE_USER: u8 = 1; const SESSION_TYPE_USER: u8 = 1;
const SESSION_TYPE_SUBSERVER: u8 = 100; const SESSION_TYPE_HOMESERVER: u8 = 100;
const LAST_BLOCK_STATE_PREFIX: &[u8] = b"SHiNE_LAST_BLOCK"; const LAST_BLOCK_STATE_PREFIX: &[u8] = b"SHiNE_LAST_BLOCK";
const IX_INIT_USERS_ECONOMY_CONFIG: u8 = 1; const IX_INIT_USERS_ECONOMY_CONFIG: u8 = 1;
@ -375,6 +375,7 @@ fn process_init_users_economy_config(program_id: &Pubkey, accounts: &[AccountInf
let signer = next_account_info(&mut it)?; let signer = next_account_info(&mut it)?;
let users_economy_config_pda = next_account_info(&mut it)?; let users_economy_config_pda = next_account_info(&mut it)?;
let system_program_ai = next_account_info(&mut it)?; let system_program_ai = next_account_info(&mut it)?;
require!(it.next().is_none(), ShineUsersError::InvalidInstruction);
require!(signer.is_signer, ShineUsersError::InvalidSigner); require!(signer.is_signer, ShineUsersError::InvalidSigner);
require_keys_eq!(*system_program_ai.key, system_program::id(), ShineUsersError::InvalidSystemProgram); require_keys_eq!(*system_program_ai.key, system_program::id(), ShineUsersError::InvalidSystemProgram);
@ -407,6 +408,7 @@ fn process_update_users_economy_config(program_id: &Pubkey, accounts: &[AccountI
let mut it = accounts.iter(); let mut it = accounts.iter();
let signer = next_account_info(&mut it)?; let signer = next_account_info(&mut it)?;
let users_economy_config_pda = next_account_info(&mut it)?; let users_economy_config_pda = next_account_info(&mut it)?;
require!(it.next().is_none(), ShineUsersError::InvalidInstruction);
require!(signer.is_signer, ShineUsersError::InvalidSigner); require!(signer.is_signer, ShineUsersError::InvalidSigner);
let dao_authority = Pubkey::from_str(settings::DAO_AUTHORITY).map_err(|_| ProgramError::from(ShineUsersError::InvalidSigner))?; let dao_authority = Pubkey::from_str(settings::DAO_AUTHORITY).map_err(|_| ProgramError::from(ShineUsersError::InvalidSigner))?;
@ -433,6 +435,7 @@ fn process_create_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args:
let instructions_sysvar = next_account_info(&mut it)?; let instructions_sysvar = next_account_info(&mut it)?;
let users_economy_config_pda = next_account_info(&mut it)?; let users_economy_config_pda = next_account_info(&mut it)?;
let login_guard_program = next_account_info(&mut it)?; let login_guard_program = next_account_info(&mut it)?;
require!(it.next().is_none(), ShineUsersError::InvalidInstruction);
require!(signer.is_signer, ShineUsersError::InvalidSigner); require!(signer.is_signer, ShineUsersError::InvalidSigner);
require_keys_eq!(*signer.key, args.fields.device_key, ShineUsersError::InvalidSigner); require_keys_eq!(*signer.key, args.fields.device_key, ShineUsersError::InvalidSigner);
@ -514,6 +517,7 @@ fn process_update_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args:
let inflow_vault = next_account_info(&mut it)?; let inflow_vault = next_account_info(&mut it)?;
let instructions_sysvar = next_account_info(&mut it)?; let instructions_sysvar = next_account_info(&mut it)?;
let users_economy_config_pda = next_account_info(&mut it)?; let users_economy_config_pda = next_account_info(&mut it)?;
require!(it.next().is_none(), ShineUsersError::InvalidInstruction);
require!(signer.is_signer, ShineUsersError::InvalidSigner); require!(signer.is_signer, ShineUsersError::InvalidSigner);
require_keys_eq!(*signer.key, args.fields.device_key, ShineUsersError::InvalidSigner); require_keys_eq!(*signer.key, args.fields.device_key, ShineUsersError::InvalidSigner);
@ -976,7 +980,7 @@ fn validate_sessions_fields(mode: u8, sessions: &[SessionRecord]) -> ProgramResu
} }
fn validate_session_record(session: &SessionRecord) -> ProgramResult { fn validate_session_record(session: &SessionRecord) -> ProgramResult {
require!(session.session_type == SESSION_TYPE_USER || session.session_type == SESSION_TYPE_SUBSERVER, ShineUsersError::InvalidRecordData); require!(session.session_type == SESSION_TYPE_USER || session.session_type == SESSION_TYPE_HOMESERVER, ShineUsersError::InvalidRecordData);
require!(session.session_version == 1, ShineUsersError::InvalidRecordData); require!(session.session_version == 1, ShineUsersError::InvalidRecordData);
let bytes = session.session_name.as_bytes(); let bytes = session.session_name.as_bytes();
require!(!bytes.is_empty(), ShineUsersError::InvalidRecordData); require!(!bytes.is_empty(), ShineUsersError::InvalidRecordData);