UI: обновлена шапка каналов, закрыты pending-задачи и обновлены версии

This commit is contained in:
AidarKC 2026-05-26 00:30:49 +03:00
parent 8941582d54
commit f1cfe9b6aa
18 changed files with 815 additions and 40 deletions

View File

@ -36,6 +36,7 @@
- `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах. - `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах.
- `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 для файлов переписок и вложений.
### Дальнее будущее ### Дальнее будущее

View File

@ -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-операции и сценарии восстановления.

175
Dev_Docs/Keys/README.md Normal file
View File

@ -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` и восстановления доступа после потери устройства;
- какие типы серверных и аппаратных сессий нужны в первой реализации.

View File

@ -1,2 +1,2 @@
client.version=1.2.91 client.version=1.2.92
server.version=1.2.85 server.version=1.2.86

View File

@ -37,6 +37,7 @@ import * as entrySettingsView from './pages/entry-settings-view.js';
import * as registerView from './pages/register-view.js'; import * as registerView from './pages/register-view.js';
import * as registrationPaymentView from './pages/registration-payment-view.js'; import * as registrationPaymentView from './pages/registration-payment-view.js';
import * as registrationKeysView from './pages/registration-keys-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 topupView from './pages/topup-view.js';
import * as loginView from './pages/login-view.js'; import * as loginView from './pages/login-view.js';
import * as loginCameraView from './pages/login-camera-view.js'; import * as loginCameraView from './pages/login-camera-view.js';
@ -78,6 +79,7 @@ const routes = {
'register-view': registerView, 'register-view': registerView,
'registration-payment-view': registrationPaymentView, 'registration-payment-view': registrationPaymentView,
'registration-keys-view': registrationKeysView, 'registration-keys-view': registrationKeysView,
'registration-draft-keys-view': registrationDraftKeysView,
'topup-view': topupView, 'topup-view': topupView,
'login-view': loginView, 'login-view': loginView,
'login-camera-view': loginCameraView, 'login-camera-view': loginCameraView,

View File

@ -1173,34 +1173,52 @@ export function render({ navigate, route }) {
const topBarEl = document.createElement('div'); const topBarEl = document.createElement('div');
topBarEl.className = 'channels-top-bar'; topBarEl.className = 'channels-top-bar';
const tabsEl = document.createElement('div'); const topBarLeft = document.createElement('div');
tabsEl.className = 'channels-tabs'; topBarLeft.className = 'channels-top-left';
const tabLabels = {
feed: 'Каналы', const backToFeedBtn = document.createElement('button');
my: 'Мои каналы', backToFeedBtn.type = 'button';
}; backToFeedBtn.className = 'icon-btn channels-top-back-btn';
TAB_ORDER.forEach((tabKey) => { backToFeedBtn.textContent = '←';
if (isGuest && tabKey === 'my') return; backToFeedBtn.setAttribute('aria-label', 'Назад');
const tabBtn = document.createElement('button'); backToFeedBtn.addEventListener('click', () => {
tabBtn.type = 'button'; if (listState.activeTab === 'feed') return;
tabBtn.className = `channels-tab-btn${listState.activeTab === tabKey ? ' is-active' : ''}`; listState.activeTab = 'feed';
tabBtn.textContent = tabLabels[tabKey] || tabKey; rerenderList();
tabBtn.addEventListener('click', () => {
if (listState.activeTab === tabKey) return;
listState.activeTab = tabKey;
rerenderList();
});
tabsEl.append(tabBtn);
}); });
const topActionBtn = document.createElement('button'); const allChannelsBtn = document.createElement('button');
topActionBtn.type = 'button'; allChannelsBtn.type = 'button';
topActionBtn.className = 'secondary-btn channels-top-action-btn'; allChannelsBtn.className = 'secondary-btn channels-top-switch-btn';
topActionBtn.textContent = 'Создать канал'; allChannelsBtn.textContent = 'Все каналы';
topActionBtn.addEventListener('click', () => navigate('add-channel-view')); allChannelsBtn.addEventListener('click', () => {
if (isGuest) topActionBtn.style.display = 'none'; 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'); const bottomCta = document.createElement('button');
bottomCta.type = 'button'; bottomCta.type = 'button';
@ -1229,8 +1247,19 @@ export function render({ navigate, route }) {
refreshFeed: reloadFeed, refreshFeed: reloadFeed,
}); });
const showCreate = !isGuest && listState.activeTab === 'my'; if (listState.activeTab === 'my' && !isGuest) {
topActionBtn.style.display = showCreate ? '' : 'none'; 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({ updateBottomCta({
button: bottomCta, button: bottomCta,
@ -1238,10 +1267,6 @@ export function render({ navigate, route }) {
navigate, navigate,
isTabEmpty, 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; let touchStartX = 0;
@ -1262,6 +1287,7 @@ export function render({ navigate, route }) {
if (index < 0) return; if (index < 0) return;
if (dx < 0 && index < TAB_ORDER.length - 1) listState.activeTab = TAB_ORDER[index + 1]; 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 (dx > 0 && index > 0) listState.activeTab = TAB_ORDER[index - 1];
if (isGuest && listState.activeTab === 'my') listState.activeTab = 'feed';
rerenderList(); rerenderList();
}, { passive: true }); }, { passive: true });

View File

@ -102,14 +102,46 @@ export function render({ navigate }) {
nextButton.addEventListener('click', async () => { nextButton.addEventListener('click', async () => {
formError.style.display = 'none'; formError.style.display = 'none';
const isFree = await runAvailabilityCheck(); const isFree = await runAvailabilityCheck();
if (!isFree) { if (!isFree) return;
return;
}
state.registrationDraft.login = loginInput.value.trim(); state.registrationDraft.login = loginInput.value.trim();
state.registrationDraft.password = passwordInput.value; 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); actions.append(backButton, nextButton);

View File

@ -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;
}

View File

@ -12,6 +12,7 @@ import {
getBalanceSol, getBalanceSol,
getTopupSiteUrl, getTopupSiteUrl,
} from '../services/solana-wallet-service.js'; } from '../services/solana-wallet-service.js';
import { registerUserOnSolana } from '../services/solana-register-service.js';
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false }; export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
const MIN_REQUIRED_SOL = 0.01; 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'); const submitButton = document.createElement('button');
submitButton.className = 'primary-btn'; submitButton.className = 'primary-btn';
submitButton.type = 'button'; submitButton.type = 'button';
@ -143,8 +150,8 @@ export function render({ navigate }) {
submitButton.addEventListener('click', async () => { submitButton.addEventListener('click', async () => {
status.style.display = 'none'; status.style.display = 'none';
const cryptoState = getCryptoRuntimeState(); const cryptoState = getCryptoRuntimeState();
if (!cryptoState.hasCrypto || !cryptoState.hasGetRandomValues || !cryptoState.hasSubtle) { if (!cryptoState.hasCrypto || !cryptoState.hasGetRandomValues || !cryptoState.hasSubtle) {
status.className = 'status-line is-unavailable'; status.className = 'status-line is-unavailable';
status.textContent = 'Криптография браузера недоступна. Откройте приложение через HTTPS tunnel или localhost и повторите регистрацию.'; status.textContent = 'Криптография браузера недоступна. Откройте приложение через HTTPS tunnel или localhost и повторите регистрацию.';
status.style.display = ''; status.style.display = '';
@ -169,6 +176,33 @@ export function render({ navigate }) {
return; 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); await authService.reconnect(state.entrySettings.shineServer);
const result = await authService.registerUser(state.registrationDraft.login, state.registrationDraft.password); const result = await authService.registerUser(state.registrationDraft.login, state.registrationDraft.password);
state.registrationDraft.flowType = 'registration'; state.registrationDraft.flowType = 'registration';
@ -200,7 +234,7 @@ export function render({ navigate }) {
`; `;
card.children[1].append(walletRow); card.children[1].append(walletRow);
card.children[2].append(balanceRow); card.children[2].append(balanceRow);
card.append(topupButton, submitButton, status); card.append(topupButton, showKeysButton, submitButton, status);
screen.append( screen.append(
renderHeader({ renderHeader({

View File

@ -7,6 +7,7 @@ export const PRE_AUTH_PAGES = [
'entry-settings-view', 'entry-settings-view',
'register-view', 'register-view',
'registration-payment-view', 'registration-payment-view',
'registration-draft-keys-view',
'registration-keys-view', 'registration-keys-view',
'topup-view', 'topup-view',
'login-view', 'login-view',

View File

@ -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 };
}

View File

@ -252,6 +252,7 @@ function createInitialState({ withStoredSession = true } = {}) {
storagePwd: '', storagePwd: '',
pendingKeyBundle: null, pendingKeyBundle: null,
pendingSessionMaterial: null, pendingSessionMaterial: null,
preGeneratedKeyBundle: null,
}, },
loginDraft: { loginDraft: {
login: storedSession?.login || '', login: storedSession?.login || '',

View File

@ -2742,6 +2742,37 @@ textarea.input {
gap: 8px; 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 { .channels-top-action-btn {
min-height: 38px; min-height: 38px;
padding: 8px 12px; padding: 8px 12px;
@ -3794,6 +3825,13 @@ textarea.input {
margin-right: 24px; 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 { .channels-screen--list .channel-row {
margin-top: 16px; margin-top: 16px;
margin-bottom: 16px; margin-bottom: 16px;