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 = `
-
-
${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..c8c112e 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,35 @@ 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);
+ 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..b4c702f
--- /dev/null
+++ b/shine-UI/js/services/auth-service.js
@@ -0,0 +1,175 @@
+import { WsJsonClient } from './ws-client.js?v=20260327192619';
+import {
+ deriveEd25519FromPassword,
+ exportEd25519PublicKeyB64,
+ exportPkcs8B64,
+ generateEd25519Pair,
+ randomBase64,
+ signBase64,
+} from './crypto-utils.js?v=20260327192619';
+import { 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 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 = '';
}