From f1cfe9b6aa86961cd327b45a452faf437eac9ddc735d689976d812f907fa7da7 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Tue, 26 May 2026 00:30:49 +0300 Subject: [PATCH] =?UTF-8?q?UI:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0=D0=BB=D0=BE=D0=B2,=20=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=D1=8B=20pending-=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B8=20=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dev_Docs/Future_Features/README.md | 1 + .../2026-05-26_0029_esp32s3_file_storage.md | 44 +++ Dev_Docs/Keys/README.md | 175 +++++++++++ .../2026-05-24_1940_solana-user-pda-v2.md | 0 .../2026-05-24_2035_solana-init-registracii.md | 0 .../2026-05-25_1545_otchet_private_zaprosov_v_gruppu.md | 0 .../2026-05-25_1556_voice_otchet_s_audio.md | 0 .../2026-05-25_2057_voice_timeout_i_oshibki.md | 0 VERSION.properties | 4 +- shine-UI/js/app.js | 2 + shine-UI/js/pages/channels-list.js | 88 ++++-- shine-UI/js/pages/register-view.js | 40 ++- .../js/pages/registration-draft-keys-view.js | 140 +++++++++ .../js/pages/registration-payment-view.js | 40 ++- shine-UI/js/router.js | 1 + .../js/services/solana-register-service.js | 281 ++++++++++++++++++ shine-UI/js/state.js | 1 + shine-UI/styles/components.css | 38 +++ 18 files changed, 815 insertions(+), 40 deletions(-) create mode 100644 Dev_Docs/Future_Features/medium/2026-05-26_0029_esp32s3_file_storage.md create mode 100644 Dev_Docs/Keys/README.md rename Dev_Docs/Pending_Features/{ => вроде сделанное}/2026-05-24_1940_solana-user-pda-v2.md (100%) rename Dev_Docs/Pending_Features/{ => вроде сделанное}/2026-05-24_2035_solana-init-registracii.md (100%) rename Dev_Docs/Pending_Features/{ => вроде сделанное}/2026-05-25_1545_otchet_private_zaprosov_v_gruppu.md (100%) rename Dev_Docs/Pending_Features/{ => вроде сделанное}/2026-05-25_1556_voice_otchet_s_audio.md (100%) rename Dev_Docs/Pending_Features/{ => вроде сделанное}/2026-05-25_2057_voice_timeout_i_oshibki.md (100%) create mode 100644 shine-UI/js/pages/registration-draft-keys-view.js create mode 100644 shine-UI/js/services/solana-register-service.js diff --git a/Dev_Docs/Future_Features/README.md b/Dev_Docs/Future_Features/README.md index 999140e..15384c3 100644 --- a/Dev_Docs/Future_Features/README.md +++ b/Dev_Docs/Future_Features/README.md @@ -36,6 +36,7 @@ - `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах. - `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн. +- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений. ### Дальнее будущее diff --git a/Dev_Docs/Future_Features/medium/2026-05-26_0029_esp32s3_file_storage.md b/Dev_Docs/Future_Features/medium/2026-05-26_0029_esp32s3_file_storage.md new file mode 100644 index 0000000..9aadb9d --- /dev/null +++ b/Dev_Docs/Future_Features/medium/2026-05-26_0029_esp32s3_file_storage.md @@ -0,0 +1,44 @@ +# ESP32S3 как личное файловое хранилище SHiNE + +## Горизонт + +Среднесрочный: ближайшие недели или 1-2 месяца. + +## Зачем нужна фича + +Нужно проработать маленький физический сервер на ESP32S3 как персональное или доверенное файловое хранилище SHiNE. + +Идея: при обмене сообщениями пользователи смогут использовать такой сервер для хранения своих файлов, вложений, файлов общих переписок и связанных данных. + +## Что нужно сделать + +- Описать роль ESP32S3-сервера в общей архитектуре ключей и сессий. +- Определить, какие ключи может хранить такое устройство. +- Решить, хранит ли устройство только файлы или также подписывает пользовательские операции. +- Описать протокол загрузки, скачивания и удаления файлов. +- Определить правила шифрования файлов до отправки на устройство. +- Продумать индексацию файлов для личных и общих переписок. +- Решить, как устройство авторизуется на основном сервере SHiNE. + +## Вопросы перед реализацией + +- ESP32S3 должен работать как полностью локальное устройство или как публично доступный мини-сервер? +- Нужен ли внешний relay, если устройство находится за NAT? +- Какие ограничения по размеру файла считаем допустимыми? +- Хранит ли устройство метаданные переписок или только зашифрованные blob-файлы? +- Как восстанавливать доступ, если устройство потеряно или заменено? + +## Что уже сделано + +Код не реализован. Идея зафиксирована как будущая задача после описания модели ключей. + +## Документы, которые нужно обновить при возврате + +- `Dev_Docs/Keys/README.md` +- `Dev_Docs/Personal_Messages/README.md` +- `Dev_Docs/API/` +- `Dev_Docs/Blockchain/`, если появятся новые блоки или команды для файлов. + +## С какого места продолжать + +Начать с короткого протокольного документа: роли устройства, авторизация, шифрование файлов, минимальные API-операции и сценарии восстановления. diff --git a/Dev_Docs/Keys/README.md b/Dev_Docs/Keys/README.md new file mode 100644 index 0000000..0e12835 --- /dev/null +++ b/Dev_Docs/Keys/README.md @@ -0,0 +1,175 @@ +# Ключи SHiNE + +Этот документ описывает роли ключей в SHiNE и их связь с Solana, персональным блокчейном, личными сообщениями, сессиями и будущими аппаратными устройствами. + +Документ является архитектурной справкой. Он не меняет текущие форматы API, DM-блоков или блокчейна сам по себе. + +## Коротко + +В SHiNE у пользователя есть несколько уровней ключей: + +- `root key` - главный корневой ключ пользователя, он же основной Solana-ключ. +- `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя. +- `device key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей. +- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере. + +Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы. + +## `root key` + +`root key` - главный ключ пользователя. + +Назначение: + +- регистрация пользователя в Solana; +- создание и обновление пользовательской PDA-записи; +- вызов критически важных Solana-функций; +- изменение главных настроек пользователя; +- управление остальными ключами; +- подтверждение операций, которые должны иметь максимальный уровень доверия. + +В текущей модели `root key` совпадает по смыслу с главным Solana-ключом пользователя. + +На `root key` могут храниться значимые средства, если пользователь сознательно выбирает такую модель. Для мелких текущих расходов предпочтительнее использовать `device key`. + +## `blockchain key` + +`blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя. + +Назначение: + +- подпись записей в персональном блокчейне пользователя; +- подтверждение действий, которые должны попасть в SHiNE-блокчейн; +- разделение полномочий между главным Solana-ключом и ключом ежедневной записи. + +У пользователя может быть несколько персональных блокчейнов или веток. При смене `blockchain key` фактически создаётся новая ветка записи: + +- `username-001` - первая ветка; +- `username-002` - вторая ветка; +- `username-003` - третья ветка. + +Рабочая логика по умолчанию должна использовать последнюю актуальную ветку. Старые ветки остаются читаемыми и показывают историю смены ключей. + +## `device key` + +`device key` - общий ключ, который знают доверенные устройства пользователя. + +Назначение: + +- повседневные входящие и исходящие личные сообщения; +- звонки и связанные с ними сообщения; +- self-messages, то есть внутренние сообщения пользователя самому себе; +- мелкие Solana-расходы на текущие операции; +- derivation Arweave-кошелька; +- оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу. + +Arweave-кошелёк должен выводиться из `device key` по протоколу: + +- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` + +Если пользователь теряет только `device key`, в худшем случае ломается повседневная переписка и доступ конкретных устройств к ежедневным операциям. `root key` и `blockchain key` при правильной архитектуре остаются отдельно защищёнными. + +## `session key` + +`session key` - уникальный ключ конкретной сессии или устройства. + +Возможные форматы: + +- `Ed25519` - предпочтительный современный вариант; +- `RSA` - legacy-вариант, полезный для устройств, где системное защищённое хранилище хорошо поддерживает RSA-ключи и не позволяет извлекать приватный ключ. + +Назначение: + +- авторизация сессии на сервере; +- привязка устройства к пользователю; +- подтверждение запросов от конкретной сессии; +- доступ к зашифрованному `device key` после успешной авторизации. + +Одна и та же сессия может быть пригодна для подключения к нескольким серверам пользователя, если архитектура конкретного пользователя это допускает. + +У сессии должны быть: + +- имя сессии; +- тип сессии; +- публичная часть ключа; +- ссылка на пользователя; +- информация о сервере или серверах, которым эта сессия доверена. + +Имя сессии может создаваться автоматически из названия устройства и короткого случайного идентификатора, например `Android-a1b2c3`, `Ubuntu-f47a90`. Пользователь может переименовать сессию. + +## Типы сессий + +Базовые типы: + +- обычная пользовательская сессия; +- серверная сессия; +- аппаратная или доверенная сессия с доступом к расширенным ключам. + +Обычное устройство обычно имеет: + +- собственный `session key`; +- зашифрованный `device key`, который открывается после авторизации; +- доступ к DM, звонкам и обычным пользовательским операциям. + +Доверенное серверное или аппаратное устройство может иметь: + +- `root key`; +- `blockchain key`; +- `device key`; +- собственный `session key`. + +Такая сессия может подписывать операции повышенной важности по запросам пользователя. + +## Внутренние self-messages + +Self-message - это сообщение пользователя самому себе. + +Такие сообщения нужны, чтобы обычное устройство могло попросить доверенное устройство выполнить действие: + +- подписать запись `blockchain key` и передать её в SHiNE-блокчейн; +- подписать изменение настройки через `root key`; +- обновить ключи; +- сохранить внутреннюю команду или настройку; +- отправить сообщение другому пользователю с сохранением копии себе; +- сохранить сообщение только себе. + +Важно: self-message не является публичной командой сервера. Это пользовательская внутренняя команда, которую сервер или доверенное устройство обрабатывает в рамках прав конкретного пользователя. + +## Шифрование входящих сообщений + +Входящее сообщение может быть зашифровано: + +- `device key`; +- `session key`; +- отдельным ключом конкретного чата; +- другим ключом, который уже известен клиенту. + +В сообщении не должно быть лишнего раскрытия того, каким именно ключом оно зашифровано. Клиент пробует расшифровать сообщение доступными ключами по порядку. Если расшифровка не удалась, сообщение остаётся непонятным для этого устройства. + +## Копии сообщений + +Для отправки сообщений нужны несколько режимов: + +- сообщение другому пользователю с исходящей копией себе; +- сообщение другому пользователю без локальной исходящей копии; +- сообщение только себе. + +Это должно позволить строить обычные DM, внутренние команды, личные заметки и зашифрованные пользовательские чаты поверх одной общей модели сообщений. + +## Связанные документы + +- `Dev_Docs/Personal_Messages/README.md` - текущая документация личных сообщений. +- `Dev_Docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна. +- `Dev_Docs/Solana_Architecture/README.md` - архитектура Solana-программ, PDA-счетов, DAO и движения средств. +- `Dev_Docs/Инициализация_Solana_регистрации/README.md` - деплой и первичная инициализация Solana-регистрации. +- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` - derivation Arweave-кошелька из `device key`. + +## Что нужно уточнить перед реализацией + +- точный формат записи списка ключей в Solana PDA; +- как именно обозначать активную ветку персонального блокчейна; +- какие операции требуют `root key`, а какие достаточно подписывать `blockchain key`; +- формат self-message-команд; +- порядок перебора ключей при расшифровке входящих сообщений; +- правила ротации `device key` и восстановления доступа после потери устройства; +- какие типы серверных и аппаратных сессий нужны в первой реализации. diff --git a/Dev_Docs/Pending_Features/2026-05-24_1940_solana-user-pda-v2.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-24_1940_solana-user-pda-v2.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-24_1940_solana-user-pda-v2.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-24_1940_solana-user-pda-v2.md diff --git a/Dev_Docs/Pending_Features/2026-05-24_2035_solana-init-registracii.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-24_2035_solana-init-registracii.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-24_2035_solana-init-registracii.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-24_2035_solana-init-registracii.md diff --git a/Dev_Docs/Pending_Features/2026-05-25_1545_otchet_private_zaprosov_v_gruppu.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-25_1545_otchet_private_zaprosov_v_gruppu.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-25_1545_otchet_private_zaprosov_v_gruppu.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-25_1545_otchet_private_zaprosov_v_gruppu.md diff --git a/Dev_Docs/Pending_Features/2026-05-25_1556_voice_otchet_s_audio.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-25_1556_voice_otchet_s_audio.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-25_1556_voice_otchet_s_audio.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-25_1556_voice_otchet_s_audio.md diff --git a/Dev_Docs/Pending_Features/2026-05-25_2057_voice_timeout_i_oshibki.md b/Dev_Docs/Pending_Features/вроде сделанное/2026-05-25_2057_voice_timeout_i_oshibki.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-05-25_2057_voice_timeout_i_oshibki.md rename to Dev_Docs/Pending_Features/вроде сделанное/2026-05-25_2057_voice_timeout_i_oshibki.md diff --git a/VERSION.properties b/VERSION.properties index ff45073..d7f42f7 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.91 -server.version=1.2.85 +client.version=1.2.92 +server.version=1.2.86 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 2cb4931..dda11da 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -37,6 +37,7 @@ import * as entrySettingsView from './pages/entry-settings-view.js'; import * as registerView from './pages/register-view.js'; import * as registrationPaymentView from './pages/registration-payment-view.js'; import * as registrationKeysView from './pages/registration-keys-view.js'; +import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js'; import * as topupView from './pages/topup-view.js'; import * as loginView from './pages/login-view.js'; import * as loginCameraView from './pages/login-camera-view.js'; @@ -78,6 +79,7 @@ const routes = { 'register-view': registerView, 'registration-payment-view': registrationPaymentView, 'registration-keys-view': registrationKeysView, + 'registration-draft-keys-view': registrationDraftKeysView, 'topup-view': topupView, 'login-view': loginView, 'login-camera-view': loginCameraView, diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index ed12df1..368d0cc 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -1173,34 +1173,52 @@ export function render({ navigate, route }) { const topBarEl = document.createElement('div'); topBarEl.className = 'channels-top-bar'; - const tabsEl = document.createElement('div'); - tabsEl.className = 'channels-tabs'; - const tabLabels = { - feed: 'Каналы', - my: 'Мои каналы', - }; - TAB_ORDER.forEach((tabKey) => { - if (isGuest && tabKey === 'my') return; - const tabBtn = document.createElement('button'); - tabBtn.type = 'button'; - tabBtn.className = `channels-tab-btn${listState.activeTab === tabKey ? ' is-active' : ''}`; - tabBtn.textContent = tabLabels[tabKey] || tabKey; - tabBtn.addEventListener('click', () => { - if (listState.activeTab === tabKey) return; - listState.activeTab = tabKey; - rerenderList(); - }); - tabsEl.append(tabBtn); + const topBarLeft = document.createElement('div'); + topBarLeft.className = 'channels-top-left'; + + const backToFeedBtn = document.createElement('button'); + backToFeedBtn.type = 'button'; + backToFeedBtn.className = 'icon-btn channels-top-back-btn'; + backToFeedBtn.textContent = '←'; + backToFeedBtn.setAttribute('aria-label', 'Назад'); + backToFeedBtn.addEventListener('click', () => { + if (listState.activeTab === 'feed') return; + listState.activeTab = 'feed'; + rerenderList(); }); - const topActionBtn = document.createElement('button'); - topActionBtn.type = 'button'; - topActionBtn.className = 'secondary-btn channels-top-action-btn'; - topActionBtn.textContent = 'Создать канал'; - topActionBtn.addEventListener('click', () => navigate('add-channel-view')); - if (isGuest) topActionBtn.style.display = 'none'; + const allChannelsBtn = document.createElement('button'); + allChannelsBtn.type = 'button'; + allChannelsBtn.className = 'secondary-btn channels-top-switch-btn'; + allChannelsBtn.textContent = 'Все каналы'; + allChannelsBtn.addEventListener('click', () => { + if (listState.activeTab === 'feed') return; + listState.activeTab = 'feed'; + rerenderList(); + }); - topBarEl.append(tabsEl, topActionBtn); + const topTitle = document.createElement('strong'); + topTitle.className = 'channels-top-title'; + + const myChannelsBtn = document.createElement('button'); + myChannelsBtn.type = 'button'; + myChannelsBtn.className = 'secondary-btn channels-top-switch-btn'; + myChannelsBtn.textContent = 'Мои каналы'; + myChannelsBtn.addEventListener('click', () => { + if (listState.activeTab === 'my') return; + listState.activeTab = 'my'; + rerenderList(); + }); + + const createInMyBtn = document.createElement('button'); + createInMyBtn.type = 'button'; + createInMyBtn.className = 'icon-btn channels-top-add-btn'; + createInMyBtn.textContent = '+'; + createInMyBtn.setAttribute('aria-label', 'Создать канал'); + createInMyBtn.addEventListener('click', () => navigate('add-channel-view')); + + topBarLeft.append(backToFeedBtn, allChannelsBtn, topTitle, myChannelsBtn); + topBarEl.append(topBarLeft, createInMyBtn); const bottomCta = document.createElement('button'); bottomCta.type = 'button'; @@ -1229,8 +1247,19 @@ export function render({ navigate, route }) { refreshFeed: reloadFeed, }); - const showCreate = !isGuest && listState.activeTab === 'my'; - topActionBtn.style.display = showCreate ? '' : 'none'; + if (listState.activeTab === 'my' && !isGuest) { + backToFeedBtn.style.display = ''; + allChannelsBtn.style.display = ''; + myChannelsBtn.style.display = 'none'; + topTitle.textContent = 'Мои каналы'; + createInMyBtn.style.display = ''; + } else { + backToFeedBtn.style.display = 'none'; + allChannelsBtn.style.display = 'none'; + myChannelsBtn.style.display = isGuest ? 'none' : ''; + topTitle.textContent = 'Каналы'; + createInMyBtn.style.display = 'none'; + } updateBottomCta({ button: bottomCta, @@ -1238,10 +1267,6 @@ export function render({ navigate, route }) { navigate, isTabEmpty, }); - tabsEl.querySelectorAll('.channels-tab-btn').forEach((btn, idx) => { - const key = TAB_ORDER[idx]; - btn.classList.toggle('is-active', key === listState.activeTab); - }); }; let touchStartX = 0; @@ -1262,6 +1287,7 @@ export function render({ navigate, route }) { if (index < 0) return; if (dx < 0 && index < TAB_ORDER.length - 1) listState.activeTab = TAB_ORDER[index + 1]; if (dx > 0 && index > 0) listState.activeTab = TAB_ORDER[index - 1]; + if (isGuest && listState.activeTab === 'my') listState.activeTab = 'feed'; rerenderList(); }, { passive: true }); diff --git a/shine-UI/js/pages/register-view.js b/shine-UI/js/pages/register-view.js index c69e0d2..b06968c 100644 --- a/shine-UI/js/pages/register-view.js +++ b/shine-UI/js/pages/register-view.js @@ -102,14 +102,46 @@ export function render({ navigate }) { nextButton.addEventListener('click', async () => { formError.style.display = 'none'; const isFree = await runAvailabilityCheck(); - if (!isFree) { - return; - } + if (!isFree) return; state.registrationDraft.login = loginInput.value.trim(); state.registrationDraft.password = passwordInput.value; + state.registrationDraft.preGeneratedKeyBundle = null; - navigate('registration-payment-view'); + // Показываем информационный экран пока генерируются ключи + form.innerHTML = ''; + const infoMsg = document.createElement('p'); + infoMsg.className = 'auth-copy'; + infoMsg.textContent = + 'Из вашего логина и пароля (надеемся, что вы выбрали достаточно длинный и надёжный пароль) ' + + 'генерируется секрет, из которого получаются root key, blockchain key и device key.'; + + const spinnerMsg = document.createElement('p'); + spinnerMsg.className = 'meta-muted'; + spinnerMsg.textContent = 'Генерация ключей...'; + + const genError = document.createElement('p'); + genError.className = 'status-line is-unavailable'; + genError.style.display = 'none'; + + form.append(infoMsg, spinnerMsg, genError); + nextButton.disabled = true; + backButton.disabled = true; + + try { + const keyBundle = await authService.derivePasswordKeyBundle( + state.registrationDraft.login, + state.registrationDraft.password, + ); + state.registrationDraft.preGeneratedKeyBundle = keyBundle; + navigate('registration-payment-view'); + } catch (error) { + genError.textContent = `Ошибка генерации ключей: ${error?.message || 'неизвестная ошибка'}`; + genError.style.display = ''; + spinnerMsg.style.display = 'none'; + nextButton.disabled = false; + backButton.disabled = false; + } }); actions.append(backButton, nextButton); diff --git a/shine-UI/js/pages/registration-draft-keys-view.js b/shine-UI/js/pages/registration-draft-keys-view.js new file mode 100644 index 0000000..a6ba325 --- /dev/null +++ b/shine-UI/js/pages/registration-draft-keys-view.js @@ -0,0 +1,140 @@ +import { renderHeader } from '../components/header.js'; +import { state } from '../state.js'; +import { bytesToBase64 } from '../services/crypto-utils.js'; +import { extractSeed32FromPkcs8B64 } from '../services/device-key-utils.js'; + +export const pageMeta = { id: 'registration-draft-keys-view', title: 'Сгенерированные ключи', showAppChrome: false }; + +function makeSecretField({ label, value }) { + const wrap = document.createElement('div'); + wrap.className = 'stack'; + + const labelEl = document.createElement('span'); + labelEl.className = 'field-label'; + labelEl.textContent = label; + + const row = document.createElement('div'); + row.className = 'inline-input-row'; + + const input = document.createElement('input'); + input.className = 'input'; + input.type = 'password'; + input.readOnly = true; + input.value = value; + + const toggleBtn = document.createElement('button'); + toggleBtn.className = 'ghost-btn'; + toggleBtn.type = 'button'; + toggleBtn.textContent = 'Показать'; + toggleBtn.addEventListener('click', () => { + if (input.type === 'password') { + input.type = 'text'; + toggleBtn.textContent = 'Скрыть'; + } else { + input.type = 'password'; + toggleBtn.textContent = 'Показать'; + } + }); + + row.append(input, toggleBtn); + wrap.append(labelEl, row); + return wrap; +} + +function makePublicField({ label, value }) { + const wrap = document.createElement('div'); + wrap.className = 'stack'; + + const labelEl = document.createElement('span'); + labelEl.className = 'field-label'; + labelEl.textContent = label; + + const input = document.createElement('input'); + input.className = 'input'; + input.type = 'text'; + input.readOnly = true; + input.value = value; + + wrap.append(labelEl, input); + return wrap; +} + +export function render({ navigate }) { + const screen = document.createElement('section'); + screen.className = 'stack'; + + const keyBundle = state.registrationDraft.preGeneratedKeyBundle; + + const card = document.createElement('div'); + card.className = 'card stack'; + + if (!keyBundle) { + const msg = document.createElement('p'); + msg.className = 'status-line is-unavailable'; + msg.textContent = 'Ключи ещё не сгенерированы. Вернитесь на экран регистрации и введите логин с паролем.'; + card.append(msg); + } else { + const warning = document.createElement('p'); + warning.className = 'meta-muted'; + warning.textContent = + 'Никому не сообщайте приватные ключи и секрет. Они не хранятся на сервере и существуют только на вашем устройстве.'; + + card.append(warning); + + // Секрет (root key seed) + let secretB64 = ''; + try { + const rootSeed32 = extractSeed32FromPkcs8B64(keyBundle.rootPair.privatePkcs8B64); + secretB64 = bytesToBase64(rootSeed32); + } catch { + secretB64 = '(не удалось извлечь)'; + } + card.append(makeSecretField({ label: 'Секрет (root seed, 32 байта)', value: secretB64 })); + + // Root key + const rootSep = document.createElement('p'); + rootSep.className = 'field-label'; + rootSep.textContent = 'Root key'; + card.append(rootSep); + card.append(makePublicField({ label: 'Root — публичный', value: keyBundle.rootPair.publicKeyB64 })); + card.append(makeSecretField({ label: 'Root — приватный (PKCS8)', value: keyBundle.rootPair.privatePkcs8B64 })); + + // Blockchain key + const bchSep = document.createElement('p'); + bchSep.className = 'field-label'; + bchSep.textContent = 'Blockchain key'; + card.append(bchSep); + card.append(makePublicField({ label: 'Blockchain — публичный', value: keyBundle.blockchainPair.publicKeyB64 })); + card.append(makeSecretField({ label: 'Blockchain — приватный (PKCS8)', value: keyBundle.blockchainPair.privatePkcs8B64 })); + + // Device key + const devSep = document.createElement('p'); + devSep.className = 'field-label'; + devSep.textContent = 'Device key (= Solana wallet)'; + card.append(devSep); + card.append(makePublicField({ label: 'Device — публичный', value: keyBundle.devicePair.publicKeyB64 })); + card.append(makeSecretField({ label: 'Device — приватный (PKCS8)', value: keyBundle.devicePair.privatePkcs8B64 })); + } + + const actions = document.createElement('div'); + actions.className = 'auth-footer-actions'; + + const backButton = document.createElement('button'); + backButton.className = 'ghost-btn'; + backButton.type = 'button'; + backButton.textContent = 'Назад'; + backButton.addEventListener('click', () => navigate('registration-payment-view')); + + actions.append(backButton); + + screen.append( + renderHeader({ + title: 'Сгенерированные ключи', + leftAction: { label: '←', onClick: () => navigate('registration-payment-view') }, + }), + card, + actions, + ); + + return screen; +} diff --git a/shine-UI/js/pages/registration-payment-view.js b/shine-UI/js/pages/registration-payment-view.js index 3d779c2..5721a5c 100644 --- a/shine-UI/js/pages/registration-payment-view.js +++ b/shine-UI/js/pages/registration-payment-view.js @@ -12,6 +12,7 @@ import { getBalanceSol, getTopupSiteUrl, } from '../services/solana-wallet-service.js'; +import { registerUserOnSolana } from '../services/solana-register-service.js'; export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false }; const MIN_REQUIRED_SOL = 0.01; @@ -136,6 +137,12 @@ export function render({ navigate }) { } }); + const showKeysButton = document.createElement('button'); + showKeysButton.className = 'ghost-btn'; + showKeysButton.type = 'button'; + showKeysButton.textContent = 'Показать сгенерированные ключи'; + showKeysButton.addEventListener('click', () => navigate('registration-draft-keys-view')); + const submitButton = document.createElement('button'); submitButton.className = 'primary-btn'; submitButton.type = 'button'; @@ -143,8 +150,8 @@ export function render({ navigate }) { submitButton.addEventListener('click', async () => { status.style.display = 'none'; - const cryptoState = getCryptoRuntimeState(); - if (!cryptoState.hasCrypto || !cryptoState.hasGetRandomValues || !cryptoState.hasSubtle) { + const cryptoState = getCryptoRuntimeState(); + if (!cryptoState.hasCrypto || !cryptoState.hasGetRandomValues || !cryptoState.hasSubtle) { status.className = 'status-line is-unavailable'; status.textContent = 'Криптография браузера недоступна. Откройте приложение через HTTPS tunnel или localhost и повторите регистрацию.'; status.style.display = ''; @@ -169,6 +176,33 @@ export function render({ navigate }) { return; } + // Используем предсгенерированный keyBundle или генерируем заново + let keyBundle = state.registrationDraft.preGeneratedKeyBundle; + if (!keyBundle) { + keyBundle = await authService.derivePasswordKeyBundle( + state.registrationDraft.login, + state.registrationDraft.password, + ); + } + + // Регистрация на Solana (смарт контракт) + submitButton.textContent = 'Регистрация в Solana...'; + try { + await registerUserOnSolana({ + login: state.registrationDraft.login, + keyBundle, + solanaEndpoint: state.entrySettings.solanaServer, + }); + } catch (solanaError) { + const solanaMsg = String(solanaError?.message || ''); + // Пользователь уже зарегистрирован в Solana — продолжаем + if (!solanaMsg.includes('already') && !solanaMsg.includes('UserAlreadyExists')) { + throw new Error(`Ошибка регистрации в Solana: ${solanaMsg}`); + } + } + + // Регистрация на сервере SHiNE + submitButton.textContent = 'Регистрация на сервере...'; await authService.reconnect(state.entrySettings.shineServer); const result = await authService.registerUser(state.registrationDraft.login, state.registrationDraft.password); state.registrationDraft.flowType = 'registration'; @@ -200,7 +234,7 @@ export function render({ navigate }) { `; card.children[1].append(walletRow); card.children[2].append(balanceRow); - card.append(topupButton, submitButton, status); + card.append(topupButton, showKeysButton, submitButton, status); screen.append( renderHeader({ diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index bb4351a..7c83a01 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -7,6 +7,7 @@ export const PRE_AUTH_PAGES = [ 'entry-settings-view', 'register-view', 'registration-payment-view', + 'registration-draft-keys-view', 'registration-keys-view', 'topup-view', 'login-view', diff --git a/shine-UI/js/services/solana-register-service.js b/shine-UI/js/services/solana-register-service.js new file mode 100644 index 0000000..34e3427 --- /dev/null +++ b/shine-UI/js/services/solana-register-service.js @@ -0,0 +1,281 @@ +import { sha256Bytes, signBytes, importPkcs8Ed25519, base64ToBytes } from './crypto-utils.js'; +import { extractSeed32FromPkcs8B64 } from './device-key-utils.js'; +import { + SHINE_USERS_PROGRAM_ID, + SHINE_PAYMENTS_PROGRAM_ID, + SHINE_LOGIN_GUARD_PROGRAM_ID, +} from '../solana-programs.js'; + +const CREATE_USER_PDA_DISCRIMINATOR = new Uint8Array([139, 157, 13, 41, 142, 174, 226, 214]); +const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111'; +const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111'; + +let solanaLibPromise = null; +function loadSolanaLib() { + if (!solanaLibPromise) solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle'); + return solanaLibPromise; +} + +function pushU32LE(buf, v) { + const n = v >>> 0; + buf.push(n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF, (n >> 24) & 0xFF); +} + +function pushU64LE(buf, bigV) { + const b = typeof bigV === 'bigint' ? bigV : BigInt(bigV); + const lo = Number(b & 0xFFFFFFFFn) >>> 0; + const hi = Number((b >> 32n) & 0xFFFFFFFFn) >>> 0; + pushU32LE(buf, lo); + pushU32LE(buf, hi); +} + +class BorshBuf { + constructor() { this._b = []; } + u8(v) { this._b.push(v & 0xFF); } + u32(v) { pushU32LE(this._b, v); } + u64(v) { pushU64LE(this._b, v); } + bool(v) { this.u8(v ? 1 : 0); } + bytes32(b) { for (const x of b) this._b.push(x); } + vecU8(b) { this.u32(b.length); for (const x of b) this._b.push(x); } + str(s) { + const enc = new TextEncoder().encode(s); + this.u32(enc.length); + for (const x of enc) this._b.push(x); + } + vecStr(arr) { + this.u32(arr.length); + for (const s of arr) this.str(s); + } + raw(bytes) { for (const x of bytes) this._b.push(x); } + result() { return new Uint8Array(this._b); } +} + +// Matches Rust serialize_last_block_state (initial zero state) +function buildLastBlockStateBytes(login, blockchainName) { + const enc = new TextEncoder(); + const prefix = enc.encode('SHiNE_LAST_BLOCK'); + const loginB = enc.encode(login); + const bchB = enc.encode(blockchainName); + const buf = []; + for (const x of prefix) buf.push(x); + buf.push(loginB.length); + for (const x of loginB) buf.push(x); + buf.push(bchB.length); + for (const x of bchB) buf.push(x); + pushU32LE(buf, 0); // last_block_number = 0 + for (let i = 0; i < 32; i++) buf.push(0); // last_block_hash = [0;32] + pushU64LE(buf, 0n); // used_bytes = 0 + return new Uint8Array(buf); +} + +// Matches Rust serialize_unsigned_record for initial registration +function buildUnsignedRecordBytes( + login, createdAtMs, rootKey32, deviceKey32, blockchainKey32, + blockchainName, paidLimitBytes, lastBlockSig64, +) { + const enc = new TextEncoder(); + const loginB = enc.encode(login); + const bchB = enc.encode(blockchainName); + const accessB = enc.encode('shineup.me'); + const buf = []; + + // Fixed header: MAGIC(5) + FORMAT_MAJOR(1) + FORMAT_MINOR(1) + record_len_placeholder(2) + buf.push(0x53, 0x48, 0x69, 0x4E, 0x45, 1, 0, 0, 0); // indices 0..8 + + pushU64LE(buf, createdAtMs); // created_at_ms + pushU64LE(buf, createdAtMs); // updated_at_ms = same + pushU32LE(buf, 0); // record_number = 0 + for (let i = 0; i < 32; i++) buf.push(0); // prev_record_hash = [0;32] + + buf.push(loginB.length); + for (const x of loginB) buf.push(x); + + buf.push(5); // blocks_count (non-server) + + // RootKeyBlock (type=1, ver=0) + buf.push(1, 0); + for (const x of rootKey32) buf.push(x); + + // DeviceKeyBlock (type=2, ver=0) + buf.push(2, 0); + for (const x of deviceKey32) buf.push(x); + + // BlockchainRegistryBlock (type=3, ver=0, count=1) + buf.push(3, 0, 1, 1); // type, ver, count=1, blockchain_type=1(MAIN_USER) + buf.push(bchB.length); + for (const x of bchB) buf.push(x); + for (const x of blockchainKey32) buf.push(x); + pushU64LE(buf, paidLimitBytes); // paid_limit_bytes + pushU64LE(buf, 0n); // used_bytes = 0 + pushU32LE(buf, 0); // last_block_number = 0 + for (let i = 0; i < 32; i++) buf.push(0); // last_block_hash = [0;32] + for (const x of lastBlockSig64) buf.push(x); // last_block_signature + buf.push(0); // arweave_present = 0 + + // AccessServersBlock (type=40, ver=0) + buf.push(40, 0, 1, accessB.length); + for (const x of accessB) buf.push(x); + + // TrustedStateBlock (type=50, ver=0, trusted_count=0) + buf.push(50, 0, 0); + + // Patch record_len at indices 7-8: total = buf.length + 64 (signature) + const recLen = buf.length + 64; + buf[7] = recLen & 0xFF; + buf[8] = (recLen >> 8) & 0xFF; + + return new Uint8Array(buf); +} + +// Builds Ed25519 program instruction data for one signature +function buildEd25519IxData(sig64, pubkey32, msgHash32) { + const sigOff = 16; + const pkOff = sigOff + 64; // 80 + const msgOff = pkOff + 32; // 112 + const data = new Uint8Array(msgOff + 32); // 144 bytes total + const v = new DataView(data.buffer); + data[0] = 1; data[1] = 0; // num_signatures=1, padding + v.setUint16(2, sigOff, true); + v.setUint16(4, 0xFFFF, true); // same instruction + v.setUint16(6, pkOff, true); + v.setUint16(8, 0xFFFF, true); + v.setUint16(10, msgOff, true); + v.setUint16(12, 32, true); // message_data_size = 32 + v.setUint16(14, 0xFFFF, true); + data.set(sig64, sigOff); + data.set(pubkey32, pkOff); + data.set(msgHash32, msgOff); + return data; +} + +function readStartBonusLimit(data) { + // Borsh: version(u8=1) + reg_fee(u64=8) + lamports_per_step(u64=8) + start_bonus_limit(u64=8) + const v = new DataView(data.buffer, data.byteOffset, data.byteLength); + return v.getBigUint64(17, true); +} + +function serializeCreateUserPdaArgs( + login, rootKey32, createdAtMs, deviceKey32, blockchainKey32, + blockchainName, lastBlockSig64, rootSig64, +) { + const b = new BorshBuf(); + b.raw(CREATE_USER_PDA_DISCRIMINATOR); + b.str(login); + b.bytes32(rootKey32); + b.u64(createdAtMs); + b.u64(0n); // additional_limit + // UserMutableFields: + b.bytes32(deviceKey32); + b.bytes32(blockchainKey32); + b.str(blockchainName); + b.u64(0n); // used_bytes + b.u32(0); // last_block_number + b.vecU8(new Uint8Array(32)); // last_block_hash + b.vecU8(lastBlockSig64); // last_block_signature + b.str(''); // arweave_tx_id + b.bool(false); // is_server + b.bytes32(new Uint8Array(32)); // server_key (default) + b.str(''); // server_address + b.vecStr([]); // sync_servers + b.vecStr(['shineup.me']); // access_servers + b.u8(0); // trusted_count + b.vecU8(rootSig64); // signature + return b.result(); +} + +export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint }) { + const solana = await loadSolanaLib(); + const connection = new solana.Connection(String(solanaEndpoint || ''), 'confirmed'); + + const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID); + const paymentsProgram = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID); + const loginGuardProgram = new solana.PublicKey(SHINE_LOGIN_GUARD_PROGRAM_ID); + const ed25519Program = new solana.PublicKey(ED25519_PROGRAM_ID); + const sysvarInstructions = new solana.PublicKey(SYSVAR_INSTRUCTIONS_ID); + + const enc = new TextEncoder(); + const loginNorm = login.toLowerCase(); + const blockchainName = `${loginNorm}-001`; + + const [userPda] = solana.PublicKey.findProgramAddressSync( + [enc.encode('login='), enc.encode(loginNorm)], + usersProgram, + ); + const [economyConfigPda] = solana.PublicKey.findProgramAddressSync( + [enc.encode('shine_users_economy_config')], + usersProgram, + ); + const [inflowVault] = solana.PublicKey.findProgramAddressSync( + [enc.encode('shine_payments_inflow_vault')], + paymentsProgram, + ); + + const rootKey32 = base64ToBytes(keyBundle.rootPair.publicKeyB64); + const blockchainKey32 = base64ToBytes(keyBundle.blockchainPair.publicKeyB64); + const deviceKey32 = base64ToBytes(keyBundle.devicePair.publicKeyB64); + + const rootPrivKey = await importPkcs8Ed25519(keyBundle.rootPair.privatePkcs8B64); + const bchPrivKey = await importPkcs8Ed25519(keyBundle.blockchainPair.privatePkcs8B64); + + const deviceSeed32 = extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64); + const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32); + + const ecoAccount = await connection.getAccountInfo(economyConfigPda); + if (!ecoAccount) { + throw new Error('Economy config не инициализирован. Запустите init_users_economy_config.'); + } + const startBonusLimit = readStartBonusLimit(ecoAccount.data); + const createdAtMs = BigInt(Date.now()); + + // Sign LastBlockState with blockchain key + const lbsBytes = buildLastBlockStateBytes(loginNorm, blockchainName); + const lbsHash = await sha256Bytes(lbsBytes); + const lastBlockSig64 = await signBytes(bchPrivKey, lbsHash); + + // Build and sign unsigned PDA record with root key + const unsignedRecord = buildUnsignedRecordBytes( + loginNorm, createdAtMs, rootKey32, deviceKey32, blockchainKey32, + blockchainName, startBonusLimit, lastBlockSig64, + ); + const unsignedHash = await sha256Bytes(unsignedRecord); + const rootSig64 = await signBytes(rootPrivKey, unsignedHash); + + const ixData = serializeCreateUserPdaArgs( + loginNorm, rootKey32, createdAtMs, deviceKey32, blockchainKey32, + blockchainName, lastBlockSig64, rootSig64, + ); + + // Ed25519 instructions must precede create_user_pda + const ed25519RootIx = new solana.TransactionInstruction({ + programId: ed25519Program, + keys: [], + data: Buffer.from(buildEd25519IxData(rootSig64, rootKey32, unsignedHash)), + }); + const ed25519BchIx = new solana.TransactionInstruction({ + programId: ed25519Program, + keys: [], + data: Buffer.from(buildEd25519IxData(lastBlockSig64, blockchainKey32, lbsHash)), + }); + const createUserIx = new solana.TransactionInstruction({ + programId: usersProgram, + keys: [ + { pubkey: deviceKeypair.publicKey, isSigner: true, isWritable: true }, + { pubkey: userPda, isSigner: false, isWritable: true }, + { pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: inflowVault, isSigner: false, isWritable: true }, + { pubkey: sysvarInstructions, isSigner: false, isWritable: false }, + { pubkey: economyConfigPda, isSigner: false, isWritable: false }, + { pubkey: loginGuardProgram, isSigner: false, isWritable: false }, + ], + data: Buffer.from(ixData), + }); + + const sig = await solana.sendAndConfirmTransaction( + connection, + new solana.Transaction().add(ed25519RootIx, ed25519BchIx, createUserIx), + [deviceKeypair], + { commitment: 'confirmed' }, + ); + + return { signature: sig, blockchainName }; +} diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index 6fcbadb..4d7dfc2 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -252,6 +252,7 @@ function createInitialState({ withStoredSession = true } = {}) { storagePwd: '', pendingKeyBundle: null, pendingSessionMaterial: null, + preGeneratedKeyBundle: null, }, loginDraft: { login: storedSession?.login || '', diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 7ee1d26..2d79815 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -2742,6 +2742,37 @@ textarea.input { gap: 8px; } +.channels-top-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.channels-top-title { + font-size: 16px; + line-height: 1.2; + color: rgba(255, 255, 255, 0.9); + white-space: nowrap; +} + +.channels-top-switch-btn { + min-height: 36px; + white-space: nowrap; +} + +.channels-top-back-btn, +.channels-top-add-btn { + width: 36px; + height: 36px; + min-width: 36px; + min-height: 36px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + .channels-top-action-btn { min-height: 38px; padding: 8px 12px; @@ -3794,6 +3825,13 @@ textarea.input { margin-right: 24px; } +.channels-screen--list .channels-top-bar { + margin-left: 24px; + margin-right: 24px; + margin-top: 16px; + margin-bottom: 12px; +} + .channels-screen--list .channel-row { margin-top: 16px; margin-bottom: 16px;