diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 9d2ca6c..bfbe63f 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -1,7 +1,7 @@ import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js?v=20260327192619'; import { renderToolbar } from './components/toolbar.js?v=20260327192619'; import { renderPageLabel } from './components/page-label.js?v=20260327192619'; -import { state, togglePageLabel } from './state.js?v=20260327192619'; +import { authService, authorizeSession, refreshSessions, state, togglePageLabel } from './state.js?v=20260327192619'; import * as startView from './pages/start-view.js?v=20260327192619'; import * as entrySettingsView from './pages/entry-settings-view.js?v=20260327192619'; @@ -113,10 +113,28 @@ function renderApp() { } } -if (!window.location.hash) { - navigate(state.session.isAuthorized ? 'profile-view' : 'start-view'); -} else { - renderApp(); +async function tryAutoLogin() { + if (!state.session.login || !state.session.sessionId) return; + try { + await authService.reconnect(state.entrySettings.shineServer); + const resumed = await authService.resumeSession(state.session.login, state.session.sessionId); + authorizeSession(resumed); + await refreshSessions(); + } catch { + // silent fallback to auth screens + } } -window.addEventListener('hashchange', renderApp); +async function init() { + await tryAutoLogin(); + + if (!window.location.hash) { + navigate(state.session.isAuthorized ? 'profile-view' : 'start-view'); + } else { + renderApp(); + } + + window.addEventListener('hashchange', renderApp); +} + +init(); diff --git a/shine-UI/js/pages/device-session-view.js b/shine-UI/js/pages/device-session-view.js index cc58476..6348c27 100644 --- a/shine-UI/js/pages/device-session-view.js +++ b/shine-UI/js/pages/device-session-view.js @@ -1,5 +1,5 @@ import { renderHeader } from '../components/header.js?v=20260327192619'; -import { deviceSessions } from '../mock-data.js?v=20260327192619'; +import { authService, refreshSessions, setAuthError, state } from '../state.js?v=20260327192619'; export const pageMeta = { id: 'device-session-view', title: 'Сеанс устройства' }; @@ -18,7 +18,7 @@ export function render({ navigate, route }) { screen.className = 'stack'; const sessionId = route?.params?.sessionId || ''; - const session = deviceSessions.find((item) => item.sessionId === sessionId) || deviceSessions[0]; + const session = (state.sessions || []).find((item) => item.sessionId === sessionId) || state.sessions[0]; screen.append( renderHeader({ @@ -27,14 +27,22 @@ export function render({ navigate, route }) { }), ); + if (!session) { + const empty = document.createElement('div'); + empty.className = 'card'; + empty.textContent = 'Сеанс не найден.'; + screen.append(empty); + return screen; + } + const details = document.createElement('div'); details.className = 'card stack'; details.innerHTML = `

sessionId

${session.sessionId}

-

clientInfoFromClient

${session.clientInfoFromClient}

-

clientInfoFromRequest

${session.clientInfoFromRequest}

-

geo

${session.geo}

-

дата/время

${formatSessionTime(session.lastAuthenticatedAtMs)}

+

clientInfoFromClient

${session.clientInfoFromClient || '-'}

+

clientInfoFromRequest

${session.clientInfoFromRequest || '-'}

+

geo

${session.geo || 'unknown'}

+

дата/время

${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}

`; const actionBtn = document.createElement('button'); @@ -42,46 +50,17 @@ export function render({ navigate, route }) { actionBtn.type = 'button'; actionBtn.textContent = 'Завершить сеанс'; - const confirmModal = document.createElement('div'); - confirmModal.className = 'modal-shell'; - confirmModal.hidden = true; - confirmModal.innerHTML = ` - - - `; - - const openModal = () => { - confirmModal.hidden = false; - confirmModal.querySelector('.modal-dialog').focus(); - }; - - const closeModal = () => { - confirmModal.hidden = true; - }; - - actionBtn.addEventListener('click', openModal); - - confirmModal.querySelector('#confirm-session-ok').addEventListener('click', closeModal); - - confirmModal.addEventListener('click', (event) => { - const target = event.target; - if (target instanceof HTMLElement && target.dataset.close === 'true') { - closeModal(); + actionBtn.addEventListener('click', async () => { + try { + await authService.closeSession(session.sessionId); + await refreshSessions(); + navigate('device-view'); + } catch (error) { + setAuthError(error.message); + window.alert(error.message); } }); - confirmModal.addEventListener('keydown', (event) => { - if (event.key === 'Escape') { - closeModal(); - } - }); - - screen.append(details, actionBtn, confirmModal); + screen.append(details, actionBtn); return screen; } diff --git a/shine-UI/js/pages/device-view.js b/shine-UI/js/pages/device-view.js index 1dec785..6cc9636 100644 --- a/shine-UI/js/pages/device-view.js +++ b/shine-UI/js/pages/device-view.js @@ -1,6 +1,11 @@ import { renderHeader } from '../components/header.js?v=20260327192619'; -import { deviceSessions } from '../mock-data.js?v=20260327192619'; -import { terminateCurrentSession } from '../state.js?v=20260327192619'; +import { + refreshSessions, + setAuthError, + setAuthInfo, + state, + terminateCurrentSession, +} from '../state.js?v=20260327192619'; export const pageMeta = { id: 'device-view', title: 'Устройства' }; @@ -28,111 +33,92 @@ export function render({ navigate }) { const actions = document.createElement('div'); actions.className = 'card stack'; actions.innerHTML = ` - + `; - actions.querySelector('#connect-device-btn').addEventListener('click', () => navigate('connect-device-view')); actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view')); const sessionsBlock = document.createElement('div'); sessionsBlock.className = 'card stack'; - const currentSession = deviceSessions[0]; - const otherSessions = deviceSessions.slice(1); + const buildList = () => { + sessionsBlock.innerHTML = ''; + const sessions = state.sessions || []; + const current = sessions.find((s) => s.sessionId === state.session.sessionId) || sessions[0]; + const others = sessions.filter((s) => s.sessionId !== current?.sessionId); - const createSessionItem = (session, isCurrent) => { - const item = document.createElement('button'); - item.className = 'session-item'; - item.type = 'button'; - item.innerHTML = ` -
-
- ${session.clientInfoFromClient} - ${session.geo} + const createSessionItem = (session, isCurrent) => { + const item = document.createElement('button'); + item.className = 'session-item'; + item.type = 'button'; + item.innerHTML = ` +
+
+ ${session.clientInfoFromClient || 'unknown client'} + ${session.geo || 'unknown'} +
+ ${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}
- ${formatSessionTime(session.lastAuthenticatedAtMs)} -
- ${ - isCurrent - ? '
Текущий сеанс
' - : '' - } - `; - item.addEventListener('click', () => navigate(`device-session-view/${session.sessionId}`)); - return item; - }; + ${isCurrent ? '
Текущий сеанс
' : ''} + `; + item.addEventListener('click', () => navigate(`device-session-view/${session.sessionId}`)); + return item; + }; - const currentMenu = document.createElement('div'); - currentMenu.className = 'stack'; - currentMenu.innerHTML = '

Текущий сеанс

'; - currentMenu.append(createSessionItem(currentSession, true)); + if (!current) { + const empty = document.createElement('p'); + empty.className = 'meta-muted'; + empty.textContent = 'Активные сессии не найдены.'; + sessionsBlock.append(empty); + return; + } - const endCurrentSessionBtn = document.createElement('button'); - endCurrentSessionBtn.className = 'text-btn'; - endCurrentSessionBtn.type = 'button'; - endCurrentSessionBtn.textContent = 'Завершить текущую сессию'; - currentMenu.append(endCurrentSessionBtn); + const currentMenu = document.createElement('div'); + currentMenu.className = 'stack'; + currentMenu.innerHTML = '

Текущий сеанс

'; + currentMenu.append(createSessionItem(current, true)); - const othersMenu = document.createElement('div'); - othersMenu.className = 'stack'; - othersMenu.innerHTML = '

Остальные активные сеансы

'; - - if (otherSessions.length === 0) { - const empty = document.createElement('p'); - empty.className = 'meta-muted'; - empty.textContent = 'Других активных сеансов нет.'; - othersMenu.append(empty); - } else { - otherSessions.forEach((session) => { - othersMenu.append(createSessionItem(session, false)); + const endCurrentSessionBtn = document.createElement('button'); + endCurrentSessionBtn.className = 'text-btn'; + endCurrentSessionBtn.type = 'button'; + endCurrentSessionBtn.textContent = 'Завершить текущую сессию'; + endCurrentSessionBtn.addEventListener('click', () => { + terminateCurrentSession(); + navigate('start-view'); }); - } + currentMenu.append(endCurrentSessionBtn); - const confirmModal = document.createElement('div'); - confirmModal.className = 'modal-shell'; - confirmModal.hidden = true; - confirmModal.innerHTML = ` - - - `; + const othersMenu = document.createElement('div'); + othersMenu.className = 'stack'; + othersMenu.innerHTML = '

Остальные активные сеансы

'; - const openModal = () => { - confirmModal.hidden = false; - confirmModal.querySelector('.modal-dialog').focus(); + if (others.length === 0) { + const empty = document.createElement('p'); + empty.className = 'meta-muted'; + empty.textContent = 'Других активных сеансов нет.'; + othersMenu.append(empty); + } else { + others.forEach((session) => { + othersMenu.append(createSessionItem(session, false)); + }); + } + + sessionsBlock.append(currentMenu, othersMenu); }; - const closeModal = () => { - confirmModal.hidden = true; - }; - - endCurrentSessionBtn.addEventListener('click', openModal); - - confirmModal.querySelector('#confirm-end-yes').addEventListener('click', () => { - terminateCurrentSession(); - navigate('start-view'); - }); - - confirmModal.addEventListener('click', (event) => { - const target = event.target; - if (target instanceof HTMLElement && target.dataset.close === 'true') { - closeModal(); + actions.querySelector('#reload-sessions-btn').addEventListener('click', async () => { + try { + await refreshSessions(); + buildList(); + setAuthInfo('Список сессий обновлён.'); + } catch (error) { + setAuthError(error.message); + window.alert(error.message); } }); - confirmModal.addEventListener('keydown', (event) => { - if (event.key === 'Escape') { - closeModal(); - } - }); - - sessionsBlock.append(currentMenu, othersMenu); - screen.append(actions, sessionsBlock, confirmModal); + buildList(); + screen.append(actions, sessionsBlock); return screen; } diff --git a/shine-UI/js/pages/login-password-view.js b/shine-UI/js/pages/login-password-view.js index dcaf910..adf79ca 100644 --- a/shine-UI/js/pages/login-password-view.js +++ b/shine-UI/js/pages/login-password-view.js @@ -1,5 +1,14 @@ import { renderHeader } from '../components/header.js?v=20260327192619'; -import { state } from '../state.js?v=20260327192619'; +import { + authService, + authorizeSession, + clearAuthMessages, + refreshSessions, + setAuthBusy, + setAuthError, + setAuthInfo, + state, +} from '../state.js?v=20260327192619'; export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false }; @@ -7,6 +16,8 @@ export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; + clearAuthMessages(); + const form = document.createElement('div'); form.className = 'card stack'; @@ -22,16 +33,9 @@ export function render({ navigate }) { passwordInput.value = state.loginDraft.password; passwordInput.placeholder = 'Введите пароль'; - const advanced = document.createElement('label'); - advanced.className = 'checkbox-row'; - advanced.innerHTML = ` Расширенные настройки`; - const advancedInput = advanced.querySelector('input'); - advancedInput.addEventListener('change', () => { - if (advancedInput.checked) { - window.alert('Расширенные настройки в стартовой версии приложения пока не используются.'); - advancedInput.checked = false; - } - }); + const hint = document.createElement('p'); + hint.className = 'meta-muted'; + hint.textContent = 'Root/dev/bch ключи вычисляются из пароля через SHA-256, storagePwd каждый вход приходит с сервера.'; form.innerHTML = ` @@ -39,7 +43,7 @@ export function render({ navigate }) { `; form.children[0].append(loginInput); form.children[1].append(passwordInput); - form.append(advanced); + form.append(hint); const actions = document.createElement('div'); actions.className = 'auth-footer-actions'; @@ -54,10 +58,36 @@ export function render({ navigate }) { enterButton.className = 'primary-btn'; enterButton.type = 'button'; enterButton.textContent = 'Войти'; - enterButton.addEventListener('click', () => { - state.loginDraft.login = loginInput.value; + enterButton.addEventListener('click', async () => { + state.loginDraft.login = loginInput.value.trim(); state.loginDraft.password = passwordInput.value; - navigate('key-storage-view'); + + if (!state.loginDraft.login || !state.loginDraft.password) { + window.alert('Введите логин и пароль'); + return; + } + + setAuthBusy(true); + setAuthError(''); + enterButton.disabled = true; + enterButton.textContent = 'Входим...'; + + try { + await authService.reconnect(state.entrySettings.shineServer); + const result = await authService.createSessionForExistingUser(state.loginDraft.login, state.loginDraft.password); + await authService.persistSessionMaterial(state.loginDraft.login, result.sessionMaterial); + authorizeSession(result); + await refreshSessions(); + setAuthInfo('Успешный вход выполнен.'); + navigate('profile-view'); + } catch (error) { + setAuthError(error.message); + window.alert(error.message); + } finally { + setAuthBusy(false); + enterButton.disabled = false; + enterButton.textContent = 'Войти'; + } }); actions.append(backButton, enterButton); diff --git a/shine-UI/js/pages/register-view.js b/shine-UI/js/pages/register-view.js index 4aa0be7..bc45531 100644 --- a/shine-UI/js/pages/register-view.js +++ b/shine-UI/js/pages/register-view.js @@ -1,5 +1,5 @@ import { renderHeader } from '../components/header.js?v=20260327192619'; -import { state } from '../state.js?v=20260327192619'; +import { authService, state } from '../state.js?v=20260327192619'; export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false }; @@ -22,16 +22,41 @@ export function render({ navigate }) { passwordInput.value = state.registrationDraft.password; passwordInput.placeholder = 'Введите пароль'; - const advanced = document.createElement('label'); - advanced.className = 'checkbox-row'; - advanced.innerHTML = ` Расширенные настройки`; - const advancedInput = advanced.querySelector('input'); - advancedInput.addEventListener('change', () => { - if (advancedInput.checked) { - window.alert('Расширенные настройки в стартовой версии не работают и не будут работать.'); - advancedInput.checked = false; + const statusText = document.createElement('p'); + statusText.className = 'meta-muted'; + statusText.textContent = 'Проверка логина: не выполнена'; + + const checkButton = document.createElement('button'); + checkButton.className = 'ghost-btn'; + checkButton.type = 'button'; + checkButton.textContent = 'Проверить логин'; + + async function runAvailabilityCheck() { + const login = loginInput.value.trim(); + if (!login) { + statusText.textContent = 'Введите логин'; + return false; } - }); + + 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'; + return isFree; + } catch (error) { + statusText.textContent = error.message; + statusText.className = 'is-unavailable'; + return false; + } finally { + checkButton.disabled = false; + checkButton.textContent = 'Проверить логин'; + } + } + + checkButton.addEventListener('click', runAvailabilityCheck); form.innerHTML = ` @@ -39,7 +64,7 @@ export function render({ navigate }) { `; form.children[0].append(loginInput); form.children[1].append(passwordInput); - form.append(advanced); + form.append(checkButton, statusText); const actions = document.createElement('div'); actions.className = 'auth-footer-actions'; @@ -54,9 +79,21 @@ export function render({ navigate }) { nextButton.className = 'primary-btn'; nextButton.type = 'button'; nextButton.textContent = 'Далее'; - nextButton.addEventListener('click', () => { - state.registrationDraft.login = loginInput.value; + nextButton.addEventListener('click', async () => { + const isFree = await runAvailabilityCheck(); + if (!isFree) { + window.alert('Выберите свободный логин'); + return; + } + + state.registrationDraft.login = loginInput.value.trim(); state.registrationDraft.password = passwordInput.value; + + if (!state.registrationDraft.password) { + window.alert('Введите пароль'); + return; + } + navigate('registration-payment-view'); }); diff --git a/shine-UI/js/pages/registration-keys-view.js b/shine-UI/js/pages/registration-keys-view.js index fd6480a..b959c80 100644 --- a/shine-UI/js/pages/registration-keys-view.js +++ b/shine-UI/js/pages/registration-keys-view.js @@ -1,5 +1,12 @@ import { renderHeader } from '../components/header.js?v=20260327192619'; -import { authorizeSession, state } from '../state.js?v=20260327192619'; +import { + authService, + authorizeSession, + refreshSessions, + setAuthError, + setAuthInfo, + state, +} from '../state.js?v=20260327192619'; export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false }; @@ -15,11 +22,11 @@ export function render({ navigate }) { const title = document.createElement('p'); title.className = 'auth-copy'; - title.textContent = `Поздравляю, ваш логин ${displayLogin} зарегистрирован.`; + title.textContent = `Отлично, логин ${displayLogin} зарегистрирован.`; const question = document.createElement('p'); question.className = 'auth-copy'; - question.textContent = 'Какие ключи вы хотите сохранить на этом устройстве?'; + question.textContent = 'Какие ключи сохранить в зашифрованном контейнере IndexedDB?'; const rootToggle = document.createElement('input'); rootToggle.type = 'checkbox'; @@ -31,19 +38,8 @@ export function render({ navigate }) { const deviceToggle = document.createElement('input'); deviceToggle.type = 'checkbox'; - deviceToggle.checked = state.keyStorage.saveDevice; - - rootToggle.addEventListener('change', () => { - state.keyStorage.saveRoot = rootToggle.checked; - }); - - blockchainToggle.addEventListener('change', () => { - state.keyStorage.saveBlockchain = blockchainToggle.checked; - }); - - deviceToggle.addEventListener('change', () => { - state.keyStorage.saveDevice = deviceToggle.checked; - }); + deviceToggle.checked = true; + deviceToggle.disabled = true; const rootRow = document.createElement('label'); rootRow.className = 'checkbox-row'; @@ -55,7 +51,7 @@ export function render({ navigate }) { const deviceRow = document.createElement('label'); deviceRow.className = 'checkbox-row'; - deviceRow.append(deviceToggle, document.createTextNode('device key')); + deviceRow.append(deviceToggle, document.createTextNode('device key (всегда)')); card.append(title, question, rootRow, deviceRow, blockchainRow); @@ -72,9 +68,41 @@ export function render({ navigate }) { okButton.className = 'primary-btn'; okButton.type = 'button'; okButton.textContent = 'OK'; - okButton.addEventListener('click', () => { - authorizeSession(); - navigate('profile-view'); + okButton.addEventListener('click', async () => { + try { + if (!state.registrationDraft.pendingKeyBundle || !state.registrationDraft.pendingSessionMaterial) { + throw new Error('Сначала завершите шаг регистрации на предыдущем экране'); + } + + state.keyStorage.saveRoot = rootToggle.checked; + state.keyStorage.saveBlockchain = blockchainToggle.checked; + + await authService.persistSelectedKeys( + state.registrationDraft.login, + state.registrationDraft.storagePwd, + state.registrationDraft.pendingKeyBundle, + { + saveRoot: state.keyStorage.saveRoot, + saveBlockchain: state.keyStorage.saveBlockchain, + }, + ); + await authService.persistSessionMaterial( + state.registrationDraft.login, + state.registrationDraft.pendingSessionMaterial, + ); + + authorizeSession({ + login: state.registrationDraft.login, + sessionId: state.registrationDraft.sessionId, + storagePwd: state.registrationDraft.storagePwd, + }); + await refreshSessions(); + setAuthInfo('Ключи сохранены, регистрация завершена.'); + navigate('profile-view'); + } catch (error) { + setAuthError(error.message); + window.alert(error.message); + } }); actions.append(cancelButton, okButton); diff --git a/shine-UI/js/pages/registration-payment-view.js b/shine-UI/js/pages/registration-payment-view.js index fce0b1c..bd70c98 100644 --- a/shine-UI/js/pages/registration-payment-view.js +++ b/shine-UI/js/pages/registration-payment-view.js @@ -1,5 +1,11 @@ import { renderHeader } from '../components/header.js?v=20260327192619'; -import { refreshRegistrationBalance, state } from '../state.js?v=20260327192619'; +import { + authService, + refreshRegistrationBalance, + setAuthError, + setAuthInfo, + state, +} from '../state.js?v=20260327192619'; export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false }; @@ -66,8 +72,28 @@ export function render({ navigate }) { submitButton.className = 'primary-btn'; submitButton.type = 'button'; submitButton.textContent = 'Зарегистрироваться'; - submitButton.addEventListener('click', () => { - navigate('registration-keys-view'); + submitButton.addEventListener('click', async () => { + try { + submitButton.disabled = true; + submitButton.textContent = 'Регистрация...'; + + await authService.reconnect(state.entrySettings.shineServer); + const result = await authService.registerUser(state.registrationDraft.login, state.registrationDraft.password); + state.registrationDraft.sessionId = result.sessionId; + state.registrationDraft.storagePwd = result.storagePwd; + state.registrationDraft.pendingKeyBundle = result.keyBundle; + state.registrationDraft.pendingSessionMaterial = result.sessionMaterial; + + setAuthInfo(`Отлично, вы зарегистрировались: ${result.login}`); + window.alert('Отлично, вы зарегистрировались'); + navigate('registration-keys-view'); + } catch (error) { + setAuthError(error.message); + window.alert(error.message); + } finally { + submitButton.disabled = false; + submitButton.textContent = 'Зарегистрироваться'; + } }); card.innerHTML = ` diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js new file mode 100644 index 0000000..2908cb9 --- /dev/null +++ b/shine-UI/js/services/auth-service.js @@ -0,0 +1,218 @@ +import { WsJsonClient } from './ws-client.js?v=20260327192619'; +import { + deriveEd25519FromPassword, + exportEd25519PublicKeyB64, + exportPkcs8B64, + generateEd25519Pair, + importPkcs8Ed25519, + randomBase64, + signBase64, +} from './crypto-utils.js?v=20260327192619'; +import { loadSessionMaterial, saveEncryptedUserSecrets, saveSessionMaterial } from './key-vault.js?v=20260327192619'; + +const BCH_SUFFIX = '001'; + +function normalizeServerUrl(url) { + const value = (url || '').trim(); + if (!value) return 'wss://shineup.me/ws'; + if (value.startsWith('ws://') || value.startsWith('wss://')) return value; + if (value.startsWith('https://') || value.startsWith('http://')) { + return `${value.replace(/^http/, 'ws').replace(/\/$/, '')}/ws`; + } + return value; +} + +function opError(op, response) { + const message = response?.payload?.message || response?.message || 'Неизвестная ошибка сервера'; + const code = response?.payload?.code || response?.code || 'UNKNOWN'; + return new Error(`${op}: ${message} (${code})`); +} + +function makeClientInfo() { + const ua = navigator.userAgent || 'unknown'; + return ua.slice(0, 50); +} + +export class AuthService { + constructor(serverUrl) { + this.serverUrl = normalizeServerUrl(serverUrl); + this.ws = new WsJsonClient(this.serverUrl); + } + + async reconnect(serverUrl) { + const normalized = normalizeServerUrl(serverUrl); + if (normalized === this.serverUrl) return; + this.ws.close(); + this.serverUrl = normalized; + this.ws = new WsJsonClient(this.serverUrl); + } + + async getUser(login) { + const response = await this.ws.request('GetUser', { login }); + if (response.status !== 200) throw opError('GetUser', response); + return response.payload || {}; + } + + async ensureLoginFree(login) { + const payload = await this.getUser(login); + return payload.exists !== true; + } + + async derivePasswordKeyBundle(password) { + if (!password) throw new Error('Введите пароль'); + const rootPair = await deriveEd25519FromPassword(password, 'root.key'); + const blockchainPair = await deriveEd25519FromPassword(password, 'bch.key'); + const devicePair = await deriveEd25519FromPassword(password, 'dev.key'); + return { rootPair, blockchainPair, devicePair }; + } + + async createAuthSession(login, keyBundle) { + const cleanLogin = (login || '').trim(); + if (!cleanLogin) throw new Error('Введите логин'); + + const sessionPair = await generateEd25519Pair(); + const sessionKeyPub = await exportEd25519PublicKeyB64(sessionPair.publicKey); + const sessionKey = `ed25519/${sessionKeyPub}`; + const storagePwd = randomBase64(32); + + const challengeResp = await this.ws.request('AuthChallenge', { login: cleanLogin }); + if (challengeResp.status !== 200) throw opError('AuthChallenge', challengeResp); + + const authNonce = challengeResp?.payload?.authNonce; + if (!authNonce) throw new Error('AuthChallenge: сервер не вернул authNonce'); + + const timeMs = Date.now(); + const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${sessionKey}:${storagePwd}:${timeMs}:${authNonce}`; + const signatureB64 = await signBase64(keyBundle.devicePair.privateKey, preimage); + + const createResp = await this.ws.request('CreateAuthSession', { + login: cleanLogin, + storagePwd, + sessionKey, + timeMs, + authNonce, + deviceKey: keyBundle.devicePair.publicKeyB64, + signatureB64, + clientInfo: makeClientInfo(), + }); + if (createResp.status !== 200) throw opError('CreateAuthSession', createResp); + + const sessionId = createResp?.payload?.sessionId; + if (!sessionId) throw new Error('CreateAuthSession: не вернулся sessionId'); + + return { + login: cleanLogin, + sessionId, + storagePwd, + sessionMaterial: { + sessionId, + sessionKey, + sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey), + }, + }; + } + + async registerUser(login, password) { + const cleanLogin = (login || '').trim(); + if (!cleanLogin) throw new Error('Введите логин'); + if (!password) throw new Error('Введите пароль'); + + const isFree = await this.ensureLoginFree(cleanLogin); + if (!isFree) throw new Error('Этот логин уже занят'); + + const keyBundle = await this.derivePasswordKeyBundle(password); + + const addResp = await this.ws.request('AddUser', { + login: cleanLogin, + blockchainName: `${cleanLogin}-${BCH_SUFFIX}`, + solanaKey: keyBundle.rootPair.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('Введите логин'); + if (!password) throw new Error('Введите пароль'); + + const user = await this.getUser(cleanLogin); + if (!user.exists) throw new Error('Пользователь не найден'); + + const keyBundle = await this.derivePasswordKeyBundle(password); + return this.createAuthSession(cleanLogin, keyBundle); + } + + async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) { + const secrets = { deviceKey: keyBundle.devicePair.privatePkcs8B64 }; + if (saveOptions.saveRoot) secrets.rootKey = keyBundle.rootPair.privatePkcs8B64; + if (saveOptions.saveBlockchain) secrets.blockchainKey = keyBundle.blockchainPair.privatePkcs8B64; + await saveEncryptedUserSecrets(login, storagePwd, secrets); + } + + async persistSessionMaterial(login, sessionMaterial) { + await saveSessionMaterial(login, sessionMaterial); + } + + + async resumeSession(login, preferredSessionId = '') { + const cleanLogin = (login || '').trim(); + if (!cleanLogin) throw new Error('Нет login для авто-входа'); + + const sessionMaterial = await loadSessionMaterial(cleanLogin); + if (!sessionMaterial?.sessionId || !sessionMaterial?.sessionKey || !sessionMaterial?.sessionPrivPkcs8) { + throw new Error('На устройстве нет сохраненного ключа сессии'); + } + + const targetSessionId = preferredSessionId || sessionMaterial.sessionId; + const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8); + + const challengeResp = await this.ws.request('SessionChallenge', { sessionId: targetSessionId }); + if (challengeResp.status !== 200) throw opError('SessionChallenge', challengeResp); + + const nonce = challengeResp?.payload?.nonce; + if (!nonce) throw new Error('SessionChallenge: не вернулся nonce'); + + const timeMs = Date.now(); + const preimage = `SESSION_LOGIN:${targetSessionId}:${timeMs}:${nonce}`; + const signatureB64 = await signBase64(privateKey, preimage); + + const loginResp = await this.ws.request('SessionLogin', { + sessionId: targetSessionId, + sessionKey: sessionMaterial.sessionKey, + timeMs, + signatureB64, + clientInfo: makeClientInfo(), + }); + if (loginResp.status !== 200) throw opError('SessionLogin', loginResp); + + const storagePwd = loginResp?.payload?.storagePwd; + if (!storagePwd) throw new Error('SessionLogin: не вернулся storagePwd'); + + return { + login: cleanLogin, + sessionId: targetSessionId, + storagePwd, + }; + } + + async listSessions() { + const response = await this.ws.request('ListSessions', {}); + if (response.status !== 200) throw opError('ListSessions', response); + return response?.payload?.sessions || []; + } + + async closeSession(sessionId) { + const response = await this.ws.request('CloseActiveSession', { sessionId }); + if (response.status !== 200) throw opError('CloseActiveSession', response); + } + + close() { + this.ws.close(); + } +} diff --git a/shine-UI/js/services/crypto-utils.js b/shine-UI/js/services/crypto-utils.js new file mode 100644 index 0000000..5db3071 --- /dev/null +++ b/shine-UI/js/services/crypto-utils.js @@ -0,0 +1,150 @@ +const encoder = new TextEncoder(); + + +function base64UrlToBase64(value) { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padLen = (4 - (normalized.length % 4)) % 4; + return normalized + '='.repeat(padLen); +} + +export function randomBase64(byteLen = 32) { + const bytes = crypto.getRandomValues(new Uint8Array(byteLen)); + return bytesToBase64(bytes); +} + +export function bytesToBase64(bytes) { + let binary = ''; + bytes.forEach((b) => { + binary += String.fromCharCode(b); + }); + return btoa(binary); +} + +export function base64ToBytes(base64) { + const normalized = (base64 || '').trim(); + const binary = atob(normalized); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +export function utf8Bytes(value) { + return encoder.encode(value); +} + +export async function sha256Bytes(bytes) { + const digest = await crypto.subtle.digest('SHA-256', bytes); + return new Uint8Array(digest); +} + +export async function sha256Text(text) { + return sha256Bytes(utf8Bytes(text)); +} + +export async function derivePasswordSeed(password, suffix) { + const base = await sha256Text(password || ''); + const concat = `${bytesToBase64(base)}${suffix}`; + return sha256Text(concat); +} + +function ed25519Pkcs8FromSeed(seed32) { + if (seed32.length !== 32) { + throw new Error('Для Ed25519 нужен seed длиной 32 байта'); + } + const prefix = new Uint8Array([ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, + ]); + const out = new Uint8Array(prefix.length + seed32.length); + out.set(prefix, 0); + out.set(seed32, prefix.length); + return out; +} + +export async function deriveEd25519FromPassword(password, suffix) { + const seed = await derivePasswordSeed(password, suffix); + const pkcs8 = ed25519Pkcs8FromSeed(seed); + const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']); + const jwk = await crypto.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 baseKey = await crypto.subtle.importKey( + 'raw', + utf8Bytes(storagePwd), + { name: 'PBKDF2' }, + false, + ['deriveKey'], + ); + + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: saltBytes, + iterations: 210000, + hash: 'SHA-256', + }, + baseKey, + { + name: 'AES-GCM', + length: 256, + }, + false, + ['encrypt', 'decrypt'], + ); +} + +export async function encryptJsonWithStoragePwd(value, storagePwd) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveAesKeyFromStoragePwd(storagePwd, salt); + const plainBytes = utf8Bytes(JSON.stringify(value)); + const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plainBytes); + + return { + saltB64: bytesToBase64(salt), + ivB64: bytesToBase64(iv), + cipherB64: bytesToBase64(new Uint8Array(cipher)), + }; +} + +export async function decryptJsonWithStoragePwd(envelope, storagePwd) { + const salt = base64ToBytes(envelope.saltB64); + const iv = base64ToBytes(envelope.ivB64); + const cipher = base64ToBytes(envelope.cipherB64); + const key = await deriveAesKeyFromStoragePwd(storagePwd, salt); + const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher); + const text = new TextDecoder().decode(plain); + return JSON.parse(text); +} + +export async function generateEd25519Pair() { + return crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']); +} + +export async function exportEd25519PublicKeyB64(publicKey) { + const raw = await crypto.subtle.exportKey('raw', publicKey); + return bytesToBase64(new Uint8Array(raw)); +} + +export async function exportPkcs8B64(privateKey) { + const raw = await crypto.subtle.exportKey('pkcs8', privateKey); + return bytesToBase64(new Uint8Array(raw)); +} + +export async function importPkcs8Ed25519(pkcs8B64) { + return crypto.subtle.importKey('pkcs8', base64ToBytes(pkcs8B64), { name: 'Ed25519' }, false, ['sign']); +} + +export async function signBase64(privateKey, text) { + const signature = await crypto.subtle.sign({ name: 'Ed25519' }, privateKey, utf8Bytes(text)); + return bytesToBase64(new Uint8Array(signature)); +} diff --git a/shine-UI/js/services/key-vault.js b/shine-UI/js/services/key-vault.js new file mode 100644 index 0000000..0447814 --- /dev/null +++ b/shine-UI/js/services/key-vault.js @@ -0,0 +1,78 @@ +import { + decryptJsonWithStoragePwd, + encryptJsonWithStoragePwd, +} from './crypto-utils.js?v=20260327192619'; + +const DB_NAME = 'shine-ui-auth'; +const DB_VERSION = 1; +const STORE_SECRETS = 'encrypted-secrets'; +const STORE_SESSIONS = 'session-keys'; + +function openDb() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_SECRETS)) { + db.createObjectStore(STORE_SECRETS, { keyPath: 'login' }); + } + if (!db.objectStoreNames.contains(STORE_SESSIONS)) { + db.createObjectStore(STORE_SESSIONS, { keyPath: 'login' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('IndexedDB недоступен')); + }); +} + +async function put(storeName, value) { + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readwrite'); + tx.objectStore(storeName).put(value); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error || new Error('Ошибка записи в IndexedDB')); + }); + db.close(); +} + +async function get(storeName, key) { + const db = await openDb(); + const result = await new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readonly'); + const req = tx.objectStore(storeName).get(key); + req.onsuccess = () => resolve(req.result || null); + req.onerror = () => reject(req.error || new Error('Ошибка чтения из IndexedDB')); + }); + db.close(); + return result; +} + +export async function saveEncryptedUserSecrets(login, storagePwd, keys) { + const encrypted = await encryptJsonWithStoragePwd(keys, storagePwd); + await put(STORE_SECRETS, { + login, + encrypted, + updatedAtMs: Date.now(), + }); +} + +export async function loadEncryptedUserSecrets(login, storagePwd) { + const row = await get(STORE_SECRETS, login); + if (!row?.encrypted) { + throw new Error('На устройстве нет сохранённых ключей для этого логина'); + } + return decryptJsonWithStoragePwd(row.encrypted, storagePwd); +} + +export async function saveSessionMaterial(login, material) { + await put(STORE_SESSIONS, { + login, + ...material, + updatedAtMs: Date.now(), + }); +} + +export async function loadSessionMaterial(login) { + return get(STORE_SESSIONS, login); +} diff --git a/shine-UI/js/services/ws-client.js b/shine-UI/js/services/ws-client.js new file mode 100644 index 0000000..ff7d06b --- /dev/null +++ b/shine-UI/js/services/ws-client.js @@ -0,0 +1,112 @@ +const DEFAULT_TIMEOUT_MS = 12000; + +function buildWsUrl(raw) { + const value = (raw || '').trim(); + if (!value) return 'wss://shineup.me/ws'; + if (value.startsWith('ws://') || value.startsWith('wss://')) return value; + if (value.startsWith('http://')) return `ws://${value.slice('http://'.length)}`; + if (value.startsWith('https://')) return `wss://${value.slice('https://'.length)}`; + return value; +} + +function createRequestId(op) { + return `${op}-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +export class WsJsonClient { + constructor(url) { + this.url = buildWsUrl(url); + this.ws = null; + this.pending = new Map(); + this.openPromise = null; + } + + async open() { + if (this.ws && this.ws.readyState === WebSocket.OPEN) return; + if (this.openPromise) return this.openPromise; + + this.openPromise = new Promise((resolve, reject) => { + const ws = new WebSocket(this.url); + this.ws = ws; + + ws.addEventListener('open', () => { + resolve(); + }, { once: true }); + + ws.addEventListener('error', () => { + reject(new Error(`Не удалось подключиться к ${this.url}`)); + }, { once: true }); + + ws.addEventListener('close', () => { + this.failPending('Соединение WebSocket закрыто'); + }); + + ws.addEventListener('message', (event) => { + this.handleMessage(event.data); + }); + }).finally(() => { + this.openPromise = null; + }); + + return this.openPromise; + } + + async request(op, payload = {}, timeoutMs = DEFAULT_TIMEOUT_MS) { + await this.open(); + const requestId = createRequestId(op); + const body = { op, requestId, payload }; + + const responsePromise = new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + this.pending.delete(requestId); + reject(new Error(`Таймаут ответа для операции ${op}`)); + }, timeoutMs); + + this.pending.set(requestId, { + resolve: (value) => { + window.clearTimeout(timer); + resolve(value); + }, + reject: (error) => { + window.clearTimeout(timer); + reject(error); + }, + }); + }); + + this.ws.send(JSON.stringify(body)); + return responsePromise; + } + + close() { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + handleMessage(raw) { + let data; + try { + data = JSON.parse(raw); + } catch { + return; + } + + const requestId = data?.requestId; + if (!requestId) return; + + const slot = this.pending.get(requestId); + if (!slot) return; + this.pending.delete(requestId); + slot.resolve(data); + } + + failPending(message) { + const error = new Error(message); + for (const [, slot] of this.pending.entries()) { + slot.reject(error); + } + this.pending.clear(); + } +} diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index c37c57c..c905e60 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -1,6 +1,36 @@ import { chatMessages, wallet } from './mock-data.js?v=20260327192619'; +import { AuthService } from './services/auth-service.js?v=20260327192619'; const clone = (value) => JSON.parse(JSON.stringify(value)); +const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1'; + +function loadStoredSession() { + try { + const raw = localStorage.getItem(SESSION_STORAGE_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch { + return null; + } +} + +function persistSession(session) { + try { + localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)); + } catch { + // ignore quota/storage errors for prototype + } +} + +function clearStoredSession() { + try { + localStorage.removeItem(SESSION_STORAGE_KEY); + } catch { + // ignore + } +} + +const storedSession = loadStoredSession(); export const state = { chats: clone(chatMessages), @@ -8,12 +38,15 @@ export const state = { pageLabelCollapsed: false, session: { isAuthorized: false, + login: storedSession?.login || '', + sessionId: storedSession?.sessionId || '', + storagePwdInMemory: '', }, startHint: '', entrySettings: { language: 'ru', solanaServer: 'https://api.mainnet-beta.solana.com', - shineServer: 'https://demo.shine.local', + shineServer: 'wss://shineup.me/ws', arweaveServer: 'https://arweave.net', statuses: { solanaServer: 'idle', @@ -24,9 +57,13 @@ export const state = { registrationDraft: { login: '', password: '', + sessionId: '', + storagePwd: '', + pendingKeyBundle: null, + pendingSessionMaterial: null, }, loginDraft: { - login: '', + login: storedSession?.login || '', password: '', }, registrationPayment: { @@ -34,10 +71,10 @@ export const state = { balanceSOL: '0.0068', }, keyStorage: { - rootKey: 'RK-4Q8N-1SZP-71LM-AUTH-ROOT', - blockchainKey: 'BK-SOL-19F2-CHAIN-ACCESS', - deviceKey: 'DK-LOCAL-82XA-DEVICE-SIGN', - saveRoot: false, + rootKey: 'Ключ root хранится в зашифрованном виде', + blockchainKey: 'Ключ blockchain хранится в зашифрованном виде', + deviceKey: 'Ключ device хранится в зашифрованном виде', + saveRoot: true, saveBlockchain: true, saveDevice: true, }, @@ -46,8 +83,16 @@ export const state = { blockchain: true, device: true, }, + authUi: { + busy: false, + error: '', + info: '', + }, + sessions: [], }; +export const authService = new AuthService(state.entrySettings.shineServer); + export function getChatMessages(chatId) { if (!state.chats[chatId]) { state.chats[chatId] = []; @@ -73,12 +118,12 @@ export function checkServerAvailability(address) { const normalized = address.trim().toLowerCase(); if (!normalized) return 'unavailable'; - const looksLikeUrl = /^https?:\/\/[a-z0-9.-]+/i.test(normalized); + const looksLikeUrl = /^(https?:\/\/|wss?:\/\/)[a-z0-9.-]+/i.test(normalized); const blockedWord = /(offline|down|fail|bad|broken|invalid)/i.test(normalized); return looksLikeUrl && !blockedWord ? 'available' : 'unavailable'; } -export function saveEntrySettings(nextSettings) { +export async function saveEntrySettings(nextSettings) { state.entrySettings = { ...state.entrySettings, ...nextSettings, @@ -87,6 +132,7 @@ export function saveEntrySettings(nextSettings) { ...(nextSettings.statuses || {}), }, }; + await authService.reconnect(state.entrySettings.shineServer); state.startHint = 'Настройки входа сохранены, адреса серверов обновлены.'; } @@ -94,13 +140,48 @@ export function clearStartHint() { state.startHint = ''; } -export function authorizeSession() { +export function setAuthBusy(flag) { + state.authUi.busy = flag; +} + +export function setAuthError(message) { + state.authUi.error = message || ''; +} + +export function setAuthInfo(message) { + state.authUi.info = message || ''; +} + +export function clearAuthMessages() { + state.authUi.error = ''; + state.authUi.info = ''; +} + +export function authorizeSession({ login, sessionId, storagePwd }) { state.session.isAuthorized = true; + state.session.login = login; + state.session.sessionId = sessionId; + state.session.storagePwdInMemory = storagePwd; + persistSession({ + isAuthorized: true, + login, + sessionId, + }); state.startHint = ''; } +export async function refreshSessions() { + state.sessions = await authService.listSessions(); + return state.sessions; +} + export function terminateCurrentSession() { state.session.isAuthorized = false; + state.session.login = ''; + state.session.sessionId = ''; + state.session.storagePwdInMemory = ''; + state.sessions = []; + clearStoredSession(); state.startHint = ''; }