From 6f0bb01b6156329f641edbee931718d8b61fb8b028cd2f776223a5324cce8a27 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 27 May 2026 18:33:26 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=BC=D0=B5=D0=B6=D1=83?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B8=D1=82:=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B4=D0=BE=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20Solana-first=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 + Dev_Docs/deploy/README.md | 4 +- SHiNE-agent-bot-coder/CLAUDE.md | 2 + VERSION.properties | 4 +- deploy_shine-PWA.sh | 66 ++++- shine-UI/CLAUDE.md | 1 + shine-UI/js/app.js | 2 + shine-UI/js/pages/devnet-topup-view.js | 131 ++++++++++ shine-UI/js/pages/register-view.js | 243 +++++++++++++++--- .../js/pages/registration-payment-view.js | 25 +- shine-UI/js/router.js | 1 + shine-UI/js/services/auth-service.js | 76 +++++- shine-UI/js/services/crypto-utils.js | 56 +++- .../js/services/solana-register-service.js | 50 +++- shine-UI/js/services/solana-wallet-service.js | 49 +++- shine-UI/js/services/ui-error-texts.js | 20 ++ shine-solana/shine/CLAUDE.md | 1 + 17 files changed, 661 insertions(+), 72 deletions(-) create mode 100644 CLAUDE.md create mode 100644 SHiNE-agent-bot-coder/CLAUDE.md create mode 100644 shine-UI/CLAUDE.md create mode 100644 shine-UI/js/pages/devnet-topup-view.js create mode 100644 shine-solana/shine/CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e8bb80d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2 @@ +@AGENTS.md +@AGENT_DEBUG_RUNBOOK.md diff --git a/Dev_Docs/deploy/README.md b/Dev_Docs/deploy/README.md index 24982da..1cb07d2 100644 --- a/Dev_Docs/deploy/README.md +++ b/Dev_Docs/deploy/README.md @@ -18,11 +18,13 @@ - Целевая директория UI-деплоя: `/home/player/SHiNE/shine-ui`. - `Caddyfile` на сервере должен смотреть в ту же директорию через `root * /home/player/SHiNE/shine-ui`. -- В `deploy_shine-PWA.sh` добавлена проверка: если `root` в `Caddyfile` не совпадает, деплой прерывается с ошибкой. +- В `deploy_shine-PWA.sh` добавлена проверка: скрипт ищет блок `shineup.me { ... }` (или значение `EXPECTED_CADDY_SITE`) и проверяет `root` внутри этого блока. +- Если `root` внутри целевого блока не совпадает, деплой прерывается с ошибкой. - Для ручного обхода проверки (только осознанно): `ALLOW_CADDY_MISMATCH=1 ./gradlew deployUI`. - При необходимости можно явно переопределить путь деплоя: - `REMOTE_UI_DIR=/нужный/путь ./gradlew deployUI` - `EXPECTED_CADDY_UI_ROOT=/нужный/путь ./gradlew deployUI` + - `EXPECTED_CADDY_SITE=example.com ./gradlew deployUI` ### Важно для локального UI (history-router / Ctrl+F5) diff --git a/SHiNE-agent-bot-coder/CLAUDE.md b/SHiNE-agent-bot-coder/CLAUDE.md new file mode 100644 index 0000000..4ed90ae --- /dev/null +++ b/SHiNE-agent-bot-coder/CLAUDE.md @@ -0,0 +1,2 @@ +@AGENTS.md +@AGENT.md diff --git a/VERSION.properties b/VERSION.properties index d7f42f7..8b28560 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.92 -server.version=1.2.86 +client.version=1.2.93 +server.version=1.2.87 diff --git a/deploy_shine-PWA.sh b/deploy_shine-PWA.sh index 968b7f5..1005356 100755 --- a/deploy_shine-PWA.sh +++ b/deploy_shine-PWA.sh @@ -6,6 +6,7 @@ REMOTE_HOST="${REMOTE_HOST:-player@45.136.124.227}" REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}" EXPECTED_CADDY_UI_ROOT="${EXPECTED_CADDY_UI_ROOT:-/home/player/SHiNE/shine-ui}" ALLOW_CADDY_MISMATCH="${ALLOW_CADDY_MISMATCH:-0}" +EXPECTED_CADDY_SITE="${EXPECTED_CADDY_SITE:-shineup.me}" BUILD_VERSION="$(date -u +%Y%m%d%H%M%S)" VERSION_FILE="VERSION.properties" export BUILD_VERSION @@ -59,9 +60,68 @@ CADDY_CONFIG_PATH="$(ssh "$REMOTE_HOST" "set -e; \ cfg=\$(printf '%s' \"\$exec_line\" | sed -n 's/.*--config \\([^ ]*\\).*/\\1/p' | head -n 1); \ if [ -z \"\$cfg\" ]; then cfg=/etc/caddy/Caddyfile; fi; \ printf '%s' \"\$cfg\"")" -CADDY_ROOT_LINE="$(ssh "$REMOTE_HOST" "sudo grep -n 'root \\* ' '$CADDY_CONFIG_PATH' | head -n 1 || true")" -if [[ -n "$CADDY_ROOT_LINE" && "$CADDY_ROOT_LINE" != *"$EXPECTED_CADDY_UI_ROOT"* ]]; then - echo "ERROR: Caddy root mismatch. Found: $CADDY_ROOT_LINE" >&2 +ROOT_CHECK_OUTPUT="$(ssh "$REMOTE_HOST" "set -euo pipefail; \ + cfg='$CADDY_CONFIG_PATH'; \ + site='$EXPECTED_CADDY_SITE'; \ + sudo awk -v site=\"\$site\" ' + BEGIN { in_site=0; depth=0; root_line=\"\"; root_lineno=0; have_site=0; } + { + line=\$0; + trimmed=line; + sub(/^[[:space:]]+/, \"\", trimmed); + sub(/[[:space:]]+$/, \"\", trimmed); + + if (!in_site) { + if (trimmed ~ \"^\" site \"[[:space:]]*\\\\{\") { + in_site=1; + have_site=1; + depth=1; + next; + } + } else { + if (trimmed ~ /^root[[:space:]]+\\*[[:space:]]+/ && root_line == \"\") { + root_line=line; + root_lineno=NR; + } + opens=gsub(/\\{/, \"{\", line); + closes=gsub(/\\}/, \"}\", line); + depth += (opens - closes); + if (depth <= 0) { + in_site=0; + } + } + } + END { + if (!have_site) { + print \"SITE_NOT_FOUND\"; + exit 0; + } + if (root_line == \"\") { + print \"ROOT_NOT_FOUND\"; + exit 0; + } + print root_lineno \":\" root_line; + } + ' \"\$cfg\"")" + +if [[ "$ROOT_CHECK_OUTPUT" == "SITE_NOT_FOUND" ]]; then + echo "ERROR: Caddy site block not found: $EXPECTED_CADDY_SITE" >&2 + echo "Caddy config: $CADDY_CONFIG_PATH" >&2 + if [[ "$ALLOW_CADDY_MISMATCH" != "1" ]]; then + echo "Set ALLOW_CADDY_MISMATCH=1 to bypass this check." >&2 + exit 1 + fi + echo "WARN: proceeding due to ALLOW_CADDY_MISMATCH=1" +elif [[ "$ROOT_CHECK_OUTPUT" == "ROOT_NOT_FOUND" ]]; then + echo "ERROR: root directive not found inside site block: $EXPECTED_CADDY_SITE" >&2 + echo "Caddy config: $CADDY_CONFIG_PATH" >&2 + if [[ "$ALLOW_CADDY_MISMATCH" != "1" ]]; then + echo "Set ALLOW_CADDY_MISMATCH=1 to bypass this check." >&2 + exit 1 + fi + echo "WARN: proceeding due to ALLOW_CADDY_MISMATCH=1" +elif [[ "$ROOT_CHECK_OUTPUT" != *"$EXPECTED_CADDY_UI_ROOT"* ]]; then + echo "ERROR: Caddy root mismatch for site $EXPECTED_CADDY_SITE. Found: $ROOT_CHECK_OUTPUT" >&2 echo "Caddy config: $CADDY_CONFIG_PATH" >&2 echo "Expected path fragment: $EXPECTED_CADDY_UI_ROOT" >&2 if [[ "$ALLOW_CADDY_MISMATCH" != "1" ]]; then diff --git a/shine-UI/CLAUDE.md b/shine-UI/CLAUDE.md new file mode 100644 index 0000000..2acea22 --- /dev/null +++ b/shine-UI/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index dda11da..afaef3f 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -39,6 +39,7 @@ 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 devnetTopupView from './pages/devnet-topup-view.js'; import * as loginView from './pages/login-view.js'; import * as loginCameraView from './pages/login-camera-view.js'; import * as loginPasswordView from './pages/login-password-view.js'; @@ -81,6 +82,7 @@ const routes = { 'registration-keys-view': registrationKeysView, 'registration-draft-keys-view': registrationDraftKeysView, 'topup-view': topupView, + 'devnet-topup-view': devnetTopupView, 'login-view': loginView, 'login-camera-view': loginCameraView, 'login-password-view': loginPasswordView, diff --git a/shine-UI/js/pages/devnet-topup-view.js b/shine-UI/js/pages/devnet-topup-view.js new file mode 100644 index 0000000..d2b187b --- /dev/null +++ b/shine-UI/js/pages/devnet-topup-view.js @@ -0,0 +1,131 @@ +import { renderHeader } from '../components/header.js'; +import { formatSol, getBalanceSol, transferSol, createSolanaWalletFromPrivateBase58 } from '../services/solana-wallet-service.js'; +import { state } from '../state.js'; + +export const pageMeta = { id: 'devnet-topup-view', title: 'Пополнение DEVNET', showAppChrome: false }; + +const SENDER_PRIVATE_32_BASE58 = '6xqAuKYvA8qrCdAkcw7Y8aMgvBnYk8JLxWLma5BzbAvu'; +const TRANSFER_AMOUNT_SOL = 0.01; + +function readWalletFromUrl() { + try { + const url = new URL(window.location.href); + return String(url.searchParams.get('wallet') || '').trim(); + } catch { + return ''; + } +} + +export function render({ navigate }) { + const screen = document.createElement('section'); + screen.className = 'stack'; + + const targetWallet = readWalletFromUrl(); + + const senderBox = document.createElement('div'); + senderBox.className = 'card stack'; + senderBox.innerHTML = ` + Тестовый DEVNET-кошелёк +

Адрес: ...

+

Баланс: ...

+ `; + + const targetBox = document.createElement('div'); + targetBox.className = 'card stack'; + targetBox.innerHTML = ` + Кошелёк получателя +

${targetWallet || 'Не передан параметр wallet'}

+

Сумма перевода: ${TRANSFER_AMOUNT_SOL} SOL

+ `; + + const status = document.createElement('p'); + status.className = 'meta-muted'; + status.textContent = 'Готово к пополнению.'; + + const fillBtn = document.createElement('button'); + fillBtn.className = 'primary-btn'; + fillBtn.type = 'button'; + fillBtn.textContent = `Пополнить на ${TRANSFER_AMOUNT_SOL} SOL`; + + const backBtn = document.createElement('button'); + backBtn.className = 'secondary-btn'; + backBtn.type = 'button'; + backBtn.textContent = 'Назад'; + backBtn.addEventListener('click', () => navigate('registration-payment-view')); + + const actions = document.createElement('div'); + actions.className = 'auth-footer-actions'; + actions.append(fillBtn, backBtn); + + let senderAddress = ''; + let senderKeypair = null; + + const updateSenderBalance = async () => { + if (!senderAddress) return; + const endpoint = state.entrySettings.solanaServer; + const balance = await getBalanceSol({ endpoint, address: senderAddress }); + const senderBalanceEl = senderBox.querySelector('#devnet-topup-sender-balance'); + if (senderBalanceEl) senderBalanceEl.textContent = `Баланс: ${formatSol(balance.sol, 6)} SOL`; + }; + + fillBtn.addEventListener('click', async () => { + if (!targetWallet) { + status.textContent = 'Ошибка: в URL не передан параметр wallet.'; + return; + } + if (!senderKeypair) { + status.textContent = 'Ошибка: кошелёк отправителя не инициализирован.'; + return; + } + + fillBtn.disabled = true; + status.textContent = 'Отправляем перевод...'; + + try { + const endpoint = state.entrySettings.solanaServer; + const tx = await transferSol({ + endpoint, + fromKeypair: senderKeypair, + toAddress: targetWallet, + amountSol: TRANSFER_AMOUNT_SOL, + }); + await updateSenderBalance(); + status.textContent = `Готово. Signature: ${tx.signature}`; + } catch (error) { + status.textContent = `Ошибка перевода: ${error?.message || 'unknown'}`; + } finally { + fillBtn.disabled = false; + } + }); + + (async () => { + try { + const sender = await createSolanaWalletFromPrivateBase58(SENDER_PRIVATE_32_BASE58); + senderAddress = sender.address; + senderKeypair = sender.keypair; + const senderAddressEl = senderBox.querySelector('#devnet-topup-sender-address'); + if (senderAddressEl) senderAddressEl.textContent = `Адрес: ${senderAddress}`; + await updateSenderBalance(); + if (!targetWallet) { + fillBtn.disabled = true; + status.textContent = 'Передайте адрес получателя в параметре wallet.'; + } + } catch (error) { + fillBtn.disabled = true; + status.textContent = `Ошибка инициализации отправителя: ${error?.message || 'unknown'}`; + } + })(); + + screen.append( + renderHeader({ + title: 'DEVNET пополнение', + leftAction: { label: '←', onClick: () => navigate('registration-payment-view') }, + }), + senderBox, + targetBox, + status, + actions, + ); + + return screen; +} diff --git a/shine-UI/js/pages/register-view.js b/shine-UI/js/pages/register-view.js index b06968c..46bb9fb 100644 --- a/shine-UI/js/pages/register-view.js +++ b/shine-UI/js/pages/register-view.js @@ -1,6 +1,7 @@ import { renderHeader } from '../components/header.js'; import { authService, clearAuthMessages, state } from '../state.js'; import { toUserMessage } from '../services/ui-error-texts.js'; +import { precheckLoginClassOnSolana } from '../services/solana-register-service.js'; export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false }; @@ -41,7 +42,7 @@ export function render({ navigate }) {

В derivation участвуют и логин, и пароль: одинаковый пароль у разных логинов даёт разные ключи.

Если пароль пустой — используется прежний тестовый режим совместимости (старый детерминированный вариант).

Для тесто оставьте пустой пароль.

-

Профиль Argon2id сейчас фиксированный: t=3, m=262144 KiB (256 MB), p=1, dkLen=32. Выбор уровня будет добавлен позже.

+

Профиль Argon2id сейчас фиксированный: t=2, m=65536 KiB (64 MB), p=1, dkLen=32. Выбор уровня будет добавлен позже.

`; const checkButton = document.createElement('button'); @@ -49,6 +50,11 @@ export function render({ navigate }) { checkButton.type = 'button'; checkButton.textContent = 'Проверить логин'; + let lastCheckedLogin = ''; + let lastCheckedFree = false; + let lastCheckedClassName = ''; + let generationRunId = 0; + async function runAvailabilityCheck() { const login = loginInput.value.trim(); if (!login) { @@ -57,15 +63,61 @@ export function render({ navigate }) { return false; } + if (login === lastCheckedLogin) { + if (!lastCheckedFree) { + statusText.textContent = 'Логин уже занят ❌'; + statusText.className = 'is-unavailable'; + } else if (lastCheckedClassName === 'free') { + statusText.textContent = 'Логин свободен ✅'; + statusText.className = 'is-available'; + } else if (lastCheckedClassName === 'premium') { + statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌'; + statusText.className = 'is-unavailable'; + } else if (lastCheckedClassName === 'company') { + statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌'; + statusText.className = 'is-unavailable'; + } else { + statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌'; + statusText.className = 'is-unavailable'; + } + formError.style.display = 'none'; + return lastCheckedFree && lastCheckedClassName === 'free'; + } + checkButton.disabled = true; checkButton.textContent = 'Проверка...'; try { await authService.reconnect(state.entrySettings.shineServer); const isFree = await authService.ensureLoginFree(login); - statusText.textContent = isFree ? 'Логин свободен ✅' : 'Логин уже занят ❌'; - statusText.className = isFree ? 'is-available' : 'is-unavailable'; + let className = ''; + if (isFree) { + const precheck = await precheckLoginClassOnSolana({ + login, + solanaEndpoint: state.entrySettings.solanaServer, + }); + className = precheck.className; + } + lastCheckedLogin = login; + lastCheckedFree = isFree; + lastCheckedClassName = className; + if (!isFree) { + statusText.textContent = 'Логин уже занят ❌'; + statusText.className = 'is-unavailable'; + } else if (className === 'free') { + statusText.textContent = 'Логин свободен ✅'; + statusText.className = 'is-available'; + } else if (className === 'premium') { + statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌'; + statusText.className = 'is-unavailable'; + } else if (className === 'company') { + statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌'; + statusText.className = 'is-unavailable'; + } else { + statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌'; + statusText.className = 'is-unavailable'; + } formError.style.display = 'none'; - return isFree; + return isFree && className === 'free'; } catch (error) { statusText.textContent = toUserMessage(error, 'Не удалось проверить логин'); statusText.className = 'is-unavailable'; @@ -78,14 +130,6 @@ export function render({ navigate }) { checkButton.addEventListener('click', runAvailabilityCheck); - form.innerHTML = ` - - - `; - form.children[0].append(loginInput); - form.children[1].append(passwordInput); - form.append(checkButton, statusText, advanced, formError); - const actions = document.createElement('div'); actions.className = 'auth-footer-actions'; @@ -104,47 +148,170 @@ export function render({ navigate }) { const isFree = await runAvailabilityCheck(); if (!isFree) return; - state.registrationDraft.login = loginInput.value.trim(); - state.registrationDraft.password = passwordInput.value; - state.registrationDraft.preGeneratedKeyBundle = null; + const prevLogin = String(state.registrationDraft.login || ''); + const prevPassword = String(state.registrationDraft.password || ''); + const nextLogin = String(loginInput.value.trim()); + const nextPassword = String(passwordInput.value || ''); + const credsChanged = prevLogin !== nextLogin || prevPassword !== nextPassword; - // Показываем информационный экран пока генерируются ключи + state.registrationDraft.login = nextLogin; + state.registrationDraft.password = nextPassword; + if (credsChanged) { + state.registrationDraft.preGeneratedKeyBundle = null; + } + + renderSecurityConfirmStage(); + }); + + actions.append(backButton, nextButton); + + function renderInputStage() { + form.innerHTML = ` + + + `; + form.children[0].append(loginInput); + form.children[1].append(passwordInput); + form.append(checkButton, statusText, advanced, formError); + actions.innerHTML = ''; + actions.append(backButton, nextButton); + backButton.disabled = false; + nextButton.disabled = false; + } + + function renderSecurityConfirmStage() { 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 info = document.createElement('p'); + info.className = 'auth-copy'; + info.textContent = + 'Для повышения безопасности мы генерируем секрет из вашего пароля с помощью Argon2id.'; + + const details = document.createElement('p'); + details.className = 'meta-muted'; + details.textContent = 'Параметры: t=2, m=65536 KiB (64 MB), p=1, dkLen=32.'; + + const details2 = document.createElement('p'); + details2.className = 'meta-muted'; + details2.textContent = + 'Из этого секрета строятся root key, blockchain key и device key. Это может занять некоторое время.'; + + const details3 = document.createElement('p'); + details3.className = 'meta-muted'; + details3.textContent = 'Это необходимо, чтобы усложнить подбор пароля и секрета.'; + + form.append(info, details, details2, details3); + + const back2 = document.createElement('button'); + back2.className = 'ghost-btn'; + back2.type = 'button'; + back2.textContent = 'Назад'; + back2.addEventListener('click', renderInputStage); + + const ok = document.createElement('button'); + ok.className = 'primary-btn'; + ok.type = 'button'; + ok.textContent = 'Окей'; + ok.addEventListener('click', startGenerationStage); + + actions.innerHTML = ''; + actions.append(back2, ok); + } + + async function startGenerationStage() { + const runId = ++generationRunId; + form.innerHTML = ''; + + const title = document.createElement('p'); + title.className = 'auth-copy'; + title.textContent = 'Генерация ключей...'; + + const subtitle = document.createElement('p'); + subtitle.className = 'meta-muted'; + subtitle.textContent = 'Генерируется секрет из вашего пароля, из которого будут вычислены все ключи.'; + + const progressWrap = document.createElement('div'); + progressWrap.style.width = '100%'; + progressWrap.style.height = '10px'; + progressWrap.style.border = '1px solid rgba(180,180,180,.5)'; + progressWrap.style.borderRadius = '6px'; + progressWrap.style.overflow = 'hidden'; + + const progressBar = document.createElement('div'); + progressBar.style.height = '100%'; + progressBar.style.width = '0%'; + progressBar.style.background = 'rgba(80, 160, 255, 0.9)'; + progressBar.style.transition = 'width 180ms linear'; + progressWrap.append(progressBar); + + const progressText = document.createElement('p'); + progressText.className = 'meta-muted'; + progressText.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; + form.append(title, subtitle, progressWrap, progressText, genError); + + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'ghost-btn'; + cancelBtn.type = 'button'; + cancelBtn.textContent = 'Отмена'; + cancelBtn.addEventListener('click', () => { + generationRunId += 1; + renderSecurityConfirmStage(); + }); + actions.innerHTML = ''; + actions.append(cancelBtn); try { - const keyBundle = await authService.derivePasswordKeyBundle( - state.registrationDraft.login, - state.registrationDraft.password, - ); - state.registrationDraft.preGeneratedKeyBundle = keyBundle; - navigate('registration-payment-view'); + if (!state.registrationDraft.preGeneratedKeyBundle) { + const keyBundle = await authService.derivePasswordKeyBundle( + state.registrationDraft.login, + state.registrationDraft.password, + { + onProgress: ({ percent, message }) => { + if (runId !== generationRunId) return; + const safePercent = Math.max(0, Math.min(100, Number(percent) || 0)); + progressBar.style.width = `${safePercent}%`; + progressText.textContent = `${safePercent}% · ${String(message || '').trim()}`; + }, + isCancelled: () => runId !== generationRunId, + }, + ); + if (runId !== generationRunId) return; + state.registrationDraft.preGeneratedKeyBundle = keyBundle; + } + if (runId !== generationRunId) return; + progressBar.style.width = '100%'; + progressText.textContent = '100%'; + title.textContent = 'Ключи сгенерированы'; + window.setTimeout(() => navigate('registration-payment-view'), 350); } catch (error) { + if (runId !== generationRunId) return; + if (String(error?.message || '') === 'DERIVE_CANCELLED') { + renderSecurityConfirmStage(); + return; + } genError.textContent = `Ошибка генерации ключей: ${error?.message || 'неизвестная ошибка'}`; genError.style.display = ''; - spinnerMsg.style.display = 'none'; - nextButton.disabled = false; - backButton.disabled = false; + const retry = document.createElement('button'); + retry.className = 'primary-btn'; + retry.type = 'button'; + retry.textContent = 'Повторить'; + retry.addEventListener('click', startGenerationStage); + const goBack = document.createElement('button'); + goBack.className = 'ghost-btn'; + goBack.type = 'button'; + goBack.textContent = 'Назад'; + goBack.addEventListener('click', renderSecurityConfirmStage); + actions.innerHTML = ''; + actions.append(goBack, retry); } - }); + } - actions.append(backButton, nextButton); + renderInputStage(); screen.append( renderHeader({ diff --git a/shine-UI/js/pages/registration-payment-view.js b/shine-UI/js/pages/registration-payment-view.js index 5721a5c..63b182a 100644 --- a/shine-UI/js/pages/registration-payment-view.js +++ b/shine-UI/js/pages/registration-payment-view.js @@ -7,7 +7,6 @@ import { } from '../state.js'; import { toUserMessage } from '../services/ui-error-texts.js'; import { - deriveWalletFromPassword, formatSol, getBalanceSol, getTopupSiteUrl, @@ -107,10 +106,14 @@ export function render({ navigate }) { }; const deriveUserWalletAddress = async () => { - const draftPassword = String(state.registrationDraft.password ?? ''); - const wallet = await deriveWalletFromPassword(draftPassword); - const address = String(wallet?.address || '').trim(); - if (!address) throw new Error('Не удалось вычислить адрес wallet.key'); + const keyBundle = state.registrationDraft.preGeneratedKeyBundle; + if (!keyBundle) throw new Error('Ключи ещё не сгенерированы. Вернитесь на предыдущий шаг.'); + const { publicKeyB64 } = keyBundle.devicePair; + const raw = atob(publicKeyB64); + const bytes = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); + const { PublicKey } = await import('https://esm.sh/@solana/web3.js@1.98.4'); + const address = new PublicKey(bytes).toBase58(); state.registrationPayment.walletAddress = address; walletValue.value = address; return address; @@ -176,14 +179,8 @@ export function render({ navigate }) { return; } - // Используем предсгенерированный keyBundle или генерируем заново - let keyBundle = state.registrationDraft.preGeneratedKeyBundle; - if (!keyBundle) { - keyBundle = await authService.derivePasswordKeyBundle( - state.registrationDraft.login, - state.registrationDraft.password, - ); - } + const keyBundle = state.registrationDraft.preGeneratedKeyBundle; + if (!keyBundle) throw new Error('Ключи не найдены. Вернитесь на предыдущий шаг.'); // Регистрация на Solana (смарт контракт) submitButton.textContent = 'Регистрация в Solana...'; @@ -204,7 +201,7 @@ export function render({ navigate }) { // Регистрация на сервере SHiNE submitButton.textContent = 'Регистрация на сервере...'; await authService.reconnect(state.entrySettings.shineServer); - const result = await authService.registerUser(state.registrationDraft.login, state.registrationDraft.password); + const result = await authService.registerUserWithKeyBundle(state.registrationDraft.login, keyBundle); state.registrationDraft.flowType = 'registration'; state.registrationDraft.sessionId = result.sessionId; state.registrationDraft.storagePwd = result.storagePwd; diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index 7c83a01..9bc56d2 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -10,6 +10,7 @@ export const PRE_AUTH_PAGES = [ 'registration-draft-keys-view', 'registration-keys-view', 'topup-view', + 'devnet-topup-view', 'login-view', 'login-camera-view', 'login-password-view', diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 15ce161..12e61d9 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -2,7 +2,8 @@ import { WsJsonClient } from './ws-client.js'; import { base64ToBytes, bytesToBase64, - deriveEd25519FromPassword, + deriveEd25519FromMasterSecret, + deriveMasterSecretFromPassword, exportEd25519PublicKeyB64, exportPkcs8B64, generateEd25519Pair, @@ -671,6 +672,8 @@ export class AuthService { this.ws = new WsJsonClient(this.serverUrl); this.headerHashCache = new Map(); this.writeLocks = new Map(); + this.passwordKeyBundleCache = new Map(); + this.passwordKeyBundleInFlight = new Map(); } async reconnect(serverUrl) { @@ -706,13 +709,56 @@ export class AuthService { return payload.exists !== true; } - async derivePasswordKeyBundle(login, password) { + async derivePasswordKeyBundle(login, password, options = {}) { const normalizedLogin = String(login ?? ''); const normalizedPassword = String(password ?? ''); - const rootPair = await deriveEd25519FromPassword(normalizedPassword, 'root.key', { login: normalizedLogin }); - const blockchainPair = await deriveEd25519FromPassword(normalizedPassword, 'bch.key', { login: normalizedLogin }); - const devicePair = await deriveEd25519FromPassword(normalizedPassword, 'dev.key', { login: normalizedLogin }); - return { rootPair, blockchainPair, devicePair }; + const cacheKey = `${normalizedLogin}\n${normalizedPassword}`; + const onProgress = typeof options?.onProgress === 'function' ? options.onProgress : null; + const isCancelled = typeof options?.isCancelled === 'function' ? options.isCancelled : null; + + if (this.passwordKeyBundleCache.has(cacheKey)) { + if (onProgress) onProgress({ percent: 100, stage: 'cached', message: 'Ключи уже сгенерированы в памяти.' }); + return this.passwordKeyBundleCache.get(cacheKey); + } + if (this.passwordKeyBundleInFlight.has(cacheKey)) { + return this.passwordKeyBundleInFlight.get(cacheKey); + } + + const task = (async () => { + if (onProgress) onProgress({ percent: 3, stage: 'prepare', message: 'Подготовка параметров генерации...' }); + if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED'); + + const masterSecret = await deriveMasterSecretFromPassword(normalizedPassword, { + login: normalizedLogin, + onProgress: (value01) => { + if (!onProgress) return; + const v = Math.max(0, Math.min(1, Number(value01) || 0)); + const percent = Math.round(5 + (v * 88)); + onProgress({ percent, stage: 'secret', message: 'Генерация секрета из пароля...' }); + }, + }); + if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED'); + + if (onProgress) onProgress({ percent: 94, stage: 'derive', message: 'Вычисление root key...' }); + const rootPair = await deriveEd25519FromMasterSecret(masterSecret, 'root.key'); + if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED'); + + if (onProgress) onProgress({ percent: 96, stage: 'derive', message: 'Вычисление blockchain key...' }); + const blockchainPair = await deriveEd25519FromMasterSecret(masterSecret, 'bch.key'); + if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED'); + + if (onProgress) onProgress({ percent: 98, stage: 'derive', message: 'Вычисление device key...' }); + const devicePair = await deriveEd25519FromMasterSecret(masterSecret, 'dev.key'); + const result = { rootPair, blockchainPair, devicePair }; + this.passwordKeyBundleCache.set(cacheKey, result); + if (onProgress) onProgress({ percent: 100, stage: 'done', message: 'Ключи сгенерированы.' }); + return result; + })().finally(() => { + this.passwordKeyBundleInFlight.delete(cacheKey); + }); + + this.passwordKeyBundleInFlight.set(cacheKey, task); + return task; } async createAuthSession(login, keyBundle) { @@ -784,6 +830,24 @@ export class AuthService { return { ...session, keyBundle }; } + async registerUserWithKeyBundle(login, keyBundle) { + const cleanLogin = (login || '').trim(); + if (!cleanLogin) throw new Error('Введите логин'); + + const addResp = await this.ws.request('AddUser', { + login: cleanLogin, + blockchainName: `${cleanLogin}-${BCH_SUFFIX}`, + solanaKey: keyBundle.devicePair.publicKeyB64, + blockchainKey: keyBundle.blockchainPair.publicKeyB64, + deviceKey: keyBundle.devicePair.publicKeyB64, + bchLimit: 1000000, + }); + if (addResp.status !== 200) throw opError('AddUser', addResp); + + const session = await this.createAuthSession(cleanLogin, keyBundle); + return { ...session, keyBundle }; + } + async createSessionForExistingUser(login, password) { const cleanLogin = (login || '').trim(); if (!cleanLogin) throw new Error('Введите логин'); diff --git a/shine-UI/js/services/crypto-utils.js b/shine-UI/js/services/crypto-utils.js index 055dd8b..63818dd 100644 --- a/shine-UI/js/services/crypto-utils.js +++ b/shine-UI/js/services/crypto-utils.js @@ -86,14 +86,29 @@ async function derivePasswordSeedArgon2id({ login, password, suffix }) { const salt = await makeArgon2Salt(normalizedLogin, normalizedSuffix); const passBytes = utf8Bytes(`${normalizedLogin}\n${normalizedPassword}`); const out = await argon2idAsync(passBytes, salt, { - t: 3, - m: 262144, + t: 2, + m: 65536, p: 1, dkLen: 32, }); return new Uint8Array(out); } +async function deriveMasterSecretArgon2id({ login, password, onProgress }) { + const normalizedLogin = normalizeLoginForKdf(login); + const normalizedPassword = String(password ?? ''); + const salt = await makeArgon2Salt(normalizedLogin, 'master.secret'); + const passBytes = utf8Bytes(`${normalizedLogin}\n${normalizedPassword}`); + const out = await argon2idAsync(passBytes, salt, { + t: 2, + m: 65536, + p: 1, + dkLen: 32, + onProgress, + }); + return new Uint8Array(out); +} + function ed25519Pkcs8FromSeed(seed32) { if (seed32.length !== 32) { throw new Error('Для Ed25519 нужен seed длиной 32 байта'); @@ -131,6 +146,43 @@ export async function deriveEd25519FromPassword(password, suffix, options = {}) }; } +export async function deriveMasterSecretFromPassword(password, options = {}) { + const normalizedPassword = String(password ?? ''); + const normalizedLogin = String(options?.login ?? ''); + const onProgress = typeof options?.onProgress === 'function' ? options.onProgress : undefined; + if (normalizedPassword.length === 0) { + const legacy = await derivePasswordSeed(normalizedPassword, 'master.secret'); + if (onProgress) onProgress(1); + return legacy; + } + return deriveMasterSecretArgon2id({ + login: normalizedLogin, + password: normalizedPassword, + onProgress, + }); +} + +export async function deriveEd25519FromMasterSecret(masterSecret32, suffix) { + const secretBytes = masterSecret32 instanceof Uint8Array + ? masterSecret32 + : new Uint8Array(masterSecret32 || []); + if (secretBytes.length !== 32) { + throw new Error('Master secret должен быть длиной 32 байта'); + } + const material = `${bytesToBase64(secretBytes)}|${String(suffix || '')}`; + const seed = await sha256Text(material); + 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 deriveAesKeyFromStoragePwd(storagePwd, saltBytes) { const subtle = getSubtleApi(); const baseKey = await subtle.importKey( diff --git a/shine-UI/js/services/solana-register-service.js b/shine-UI/js/services/solana-register-service.js index 34e3427..2269601 100644 --- a/shine-UI/js/services/solana-register-service.js +++ b/shine-UI/js/services/solana-register-service.js @@ -7,6 +7,7 @@ import { } from '../solana-programs.js'; const CREATE_USER_PDA_DISCRIMINATOR = new Uint8Array([139, 157, 13, 41, 142, 174, 226, 214]); +const CLASSIFY_LOGIN_DISCRIMINATOR = new Uint8Array([118, 253, 204, 124, 22, 232, 235, 32]); const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111'; const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111'; @@ -183,6 +184,49 @@ function serializeCreateUserPdaArgs( return b.result(); } +function serializeClassifyLoginArgs(login) { + const b = new BorshBuf(); + b.raw(CLASSIFY_LOGIN_DISCRIMINATOR); + b.str(String(login || '')); + return b.result(); +} + +function decodeU32FromB64(rawB64) { + const bytes = Uint8Array.from(atob(rawB64), (ch) => ch.charCodeAt(0)); + if (bytes.length < 4) throw new Error('LOGIN_GUARD_BAD_RETURN_DATA'); + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0, true); +} + +export async function precheckLoginClassOnSolana({ login, solanaEndpoint }) { + const solana = await loadSolanaLib(); + const connection = new solana.Connection(String(solanaEndpoint || ''), 'confirmed'); + const loginGuardProgram = new solana.PublicKey(SHINE_LOGIN_GUARD_PROGRAM_ID); + const signer = solana.Keypair.generate(); + const ix = new solana.TransactionInstruction({ + programId: loginGuardProgram, + keys: [{ pubkey: signer.publicKey, isSigner: true, isWritable: false }], + data: serializeClassifyLoginArgs(String(login || '').toLowerCase()), + }); + const tx = new solana.Transaction().add(ix); + tx.feePayer = signer.publicKey; + tx.recentBlockhash = (await connection.getLatestBlockhash('confirmed')).blockhash; + tx.sign(signer); + + const sim = await connection.simulateTransaction(tx, { commitment: 'confirmed', sigVerify: true }); + if (sim?.value?.err) { + throw new Error(`LOGIN_GUARD_SIMULATION_FAILED: ${JSON.stringify(sim.value.err)}`); + } + const returnData = sim?.value?.returnData; + if (!returnData || returnData.programId !== SHINE_LOGIN_GUARD_PROGRAM_ID) { + throw new Error('LOGIN_GUARD_BAD_RETURN_DATA'); + } + const classValue = decodeU32FromB64(returnData.data?.[0] || ''); + if (classValue === 0) return { classCode: 0, className: 'free' }; + if (classValue === 1) return { classCode: 1, className: 'premium' }; + if (classValue === 2) return { classCode: 2, className: 'company' }; + return { classCode: classValue, className: 'unknown' }; +} + export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint }) { const solana = await loadSolanaLib(); const connection = new solana.Connection(String(solanaEndpoint || ''), 'confirmed'); @@ -249,12 +293,12 @@ export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint }) const ed25519RootIx = new solana.TransactionInstruction({ programId: ed25519Program, keys: [], - data: Buffer.from(buildEd25519IxData(rootSig64, rootKey32, unsignedHash)), + data: buildEd25519IxData(rootSig64, rootKey32, unsignedHash), }); const ed25519BchIx = new solana.TransactionInstruction({ programId: ed25519Program, keys: [], - data: Buffer.from(buildEd25519IxData(lastBlockSig64, blockchainKey32, lbsHash)), + data: buildEd25519IxData(lastBlockSig64, blockchainKey32, lbsHash), }); const createUserIx = new solana.TransactionInstruction({ programId: usersProgram, @@ -267,7 +311,7 @@ export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint }) { pubkey: economyConfigPda, isSigner: false, isWritable: false }, { pubkey: loginGuardProgram, isSigner: false, isWritable: false }, ], - data: Buffer.from(ixData), + data: ixData, }); const sig = await solana.sendAndConfirmTransaction( diff --git a/shine-UI/js/services/solana-wallet-service.js b/shine-UI/js/services/solana-wallet-service.js index c91345d..4c5a882 100644 --- a/shine-UI/js/services/solana-wallet-service.js +++ b/shine-UI/js/services/solana-wallet-service.js @@ -4,10 +4,18 @@ import { loadEncryptedUserSecrets } from './key-vault.js'; import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js'; const DEFAULT_SOLANA_ENDPOINT = SOLANA_ENDPOINT_DEFAULT; -const TOPUP_SITE_URL = 'https://shine-promo-solana-devnet.shineup.me/'; +const TOPUP_SITE_URL = '/devnet-topup-view'; let solanaLibPromise = null; const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/; +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; +const BASE58_MAP = (() => { + const out = Object.create(null); + for (let i = 0; i < BASE58_ALPHABET.length; i += 1) { + out[BASE58_ALPHABET[i]] = i; + } + return out; +})(); function normalizeEndpoint(url) { const raw = String(url || '').trim(); @@ -22,6 +30,38 @@ async function loadSolanaLib() { return solanaLibPromise; } +function decodeBase58(input) { + const text = String(input || '').trim(); + if (!text) return new Uint8Array(0); + + const bytes = [0]; + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + const value = BASE58_MAP[ch]; + if (value == null) { + throw new Error('Недопустимый символ Base58'); + } + + let carry = value; + for (let j = 0; j < bytes.length; j += 1) { + const x = (bytes[j] * 58) + carry; + bytes[j] = x & 0xff; + carry = x >> 8; + } + while (carry > 0) { + bytes.push(carry & 0xff); + carry >>= 8; + } + } + + for (let i = 0; i < text.length && text[i] === '1'; i += 1) { + bytes.push(0); + } + + bytes.reverse(); + return Uint8Array.from(bytes); +} + async function keypairFromPkcs8(pkcs8B64) { const solana = await loadSolanaLib(); const seed32 = extractDeviceKey32FromStoredValue(pkcs8B64); @@ -55,7 +95,7 @@ export async function createSolanaWalletFromPrivateBase58(privateKey32Base58) { const clean = String(privateKey32Base58 || '').trim(); if (!clean) throw new Error('Введите приватный ключ'); if (!BASE58_RE.test(clean)) throw new Error('Разрешены только символы Base58'); - const privateBytes = solana.bs58.decode(clean); + const privateBytes = decodeBase58(clean); if (privateBytes.length !== 32) { throw new Error('Приватный ключ должен быть ровно 32 байта в Base58'); } @@ -149,7 +189,10 @@ export function getTopupSiteUrl(walletAddress = '') { const cleanWallet = String(walletAddress || '').trim(); if (!cleanWallet) return TOPUP_SITE_URL; try { - const url = new URL(TOPUP_SITE_URL); + const base = typeof window !== 'undefined' && window.location?.origin + ? window.location.origin + : 'http://localhost:8088'; + const url = new URL(TOPUP_SITE_URL, base); url.searchParams.set('wallet', cleanWallet); return url.toString(); } catch { diff --git a/shine-UI/js/services/ui-error-texts.js b/shine-UI/js/services/ui-error-texts.js index 8cc22f3..384b331 100644 --- a/shine-UI/js/services/ui-error-texts.js +++ b/shine-UI/js/services/ui-error-texts.js @@ -50,6 +50,26 @@ export function toUserMessage(error, fallback = 'Действие не выпо return 'Пользователь не найден. Проверьте логин.'; } + if ( + code === 'PREMIUMLOGIN' || + text.includes('premiumlogin') || + text.includes('error code: premiumlogin') || + text.includes('логин относится к платным') + ) { + return 'Этот логин относится к премиум-категории. Для него нужна отдельная покупка через DAO.'; + } + + if ( + code === 'TRADEMARKLOGINREQUIRESREVIEW' || + text.includes('trademarkloginrequiresreview') || + text.includes('companyloginrequiresreview') || + text.includes('логин компании') || + text.includes('логин относится к компаниям') || + text.includes('требует отдельного согласования') + ) { + return 'Этот логин относится к компании/бренду. Для него нужен отдельный процесс согласования.'; + } + if ( code === 'BAD_CHANNEL_NAME' || text.includes('channel name must match') || diff --git a/shine-solana/shine/CLAUDE.md b/shine-solana/shine/CLAUDE.md new file mode 100644 index 0000000..2acea22 --- /dev/null +++ b/shine-solana/shine/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md