Derive root/device/blockchain keys from password SHA-256
This commit is contained in:
parent
1bf1c768dd
commit
4f825e2a86
@ -1,5 +1,5 @@
|
|||||||
import { renderHeader } from '../components/header.js?v=20260327192619';
|
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: 'Сеанс устройства' };
|
export const pageMeta = { id: 'device-session-view', title: 'Сеанс устройства' };
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ export function render({ navigate, route }) {
|
|||||||
screen.className = 'stack';
|
screen.className = 'stack';
|
||||||
|
|
||||||
const sessionId = route?.params?.sessionId || '';
|
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(
|
screen.append(
|
||||||
renderHeader({
|
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');
|
const details = document.createElement('div');
|
||||||
details.className = 'card stack';
|
details.className = 'card stack';
|
||||||
details.innerHTML = `
|
details.innerHTML = `
|
||||||
<div><p class="meta-muted">sessionId</p><p>${session.sessionId}</p></div>
|
<div><p class="meta-muted">sessionId</p><p>${session.sessionId}</p></div>
|
||||||
<div><p class="meta-muted">clientInfoFromClient</p><p>${session.clientInfoFromClient}</p></div>
|
<div><p class="meta-muted">clientInfoFromClient</p><p>${session.clientInfoFromClient || '-'}</p></div>
|
||||||
<div><p class="meta-muted">clientInfoFromRequest</p><p>${session.clientInfoFromRequest}</p></div>
|
<div><p class="meta-muted">clientInfoFromRequest</p><p>${session.clientInfoFromRequest || '-'}</p></div>
|
||||||
<div><p class="meta-muted">geo</p><p>${session.geo}</p></div>
|
<div><p class="meta-muted">geo</p><p>${session.geo || 'unknown'}</p></div>
|
||||||
<div><p class="meta-muted">дата/время</p><p>${formatSessionTime(session.lastAuthenticatedAtMs)}</p></div>
|
<div><p class="meta-muted">дата/время</p><p>${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</p></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const actionBtn = document.createElement('button');
|
const actionBtn = document.createElement('button');
|
||||||
@ -42,46 +50,17 @@ export function render({ navigate, route }) {
|
|||||||
actionBtn.type = 'button';
|
actionBtn.type = 'button';
|
||||||
actionBtn.textContent = 'Завершить сеанс';
|
actionBtn.textContent = 'Завершить сеанс';
|
||||||
|
|
||||||
const confirmModal = document.createElement('div');
|
actionBtn.addEventListener('click', async () => {
|
||||||
confirmModal.className = 'modal-shell';
|
try {
|
||||||
confirmModal.hidden = true;
|
await authService.closeSession(session.sessionId);
|
||||||
confirmModal.innerHTML = `
|
await refreshSessions();
|
||||||
<div class="modal-backdrop" data-close="true"></div>
|
navigate('device-view');
|
||||||
<div class="modal-dialog card" role="dialog" aria-modal="true" tabindex="-1">
|
} catch (error) {
|
||||||
<p>Вы уверены, что хотите завершить этот сеанс?</p>
|
setAuthError(error.message);
|
||||||
<div class="auth-footer-actions">
|
window.alert(error.message);
|
||||||
<button class="primary-btn" type="button" id="confirm-session-ok">ОК</button>
|
|
||||||
<button class="ghost-btn" type="button" data-close="true">Отмена</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
confirmModal.addEventListener('keydown', (event) => {
|
screen.append(details, actionBtn);
|
||||||
if (event.key === 'Escape') {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
screen.append(details, actionBtn, confirmModal);
|
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { renderHeader } from '../components/header.js?v=20260327192619';
|
import { renderHeader } from '../components/header.js?v=20260327192619';
|
||||||
import { deviceSessions } from '../mock-data.js?v=20260327192619';
|
import {
|
||||||
import { terminateCurrentSession } from '../state.js?v=20260327192619';
|
refreshSessions,
|
||||||
|
setAuthError,
|
||||||
|
setAuthInfo,
|
||||||
|
state,
|
||||||
|
terminateCurrentSession,
|
||||||
|
} from '../state.js?v=20260327192619';
|
||||||
|
|
||||||
export const pageMeta = { id: 'device-view', title: 'Устройства' };
|
export const pageMeta = { id: 'device-view', title: 'Устройства' };
|
||||||
|
|
||||||
@ -28,18 +33,20 @@ export function render({ navigate }) {
|
|||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'card stack';
|
actions.className = 'card stack';
|
||||||
actions.innerHTML = `
|
actions.innerHTML = `
|
||||||
<button class="primary-btn" type="button" id="connect-device-btn">Подключить устройство</button>
|
<button class="primary-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
|
||||||
<button class="text-btn" type="button" id="show-keys-btn">Показать ключи</button>
|
<button class="text-btn" type="button" id="show-keys-btn">Показать ключи</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
actions.querySelector('#connect-device-btn').addEventListener('click', () => navigate('connect-device-view'));
|
|
||||||
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
|
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
|
||||||
|
|
||||||
const sessionsBlock = document.createElement('div');
|
const sessionsBlock = document.createElement('div');
|
||||||
sessionsBlock.className = 'card stack';
|
sessionsBlock.className = 'card stack';
|
||||||
|
|
||||||
const currentSession = deviceSessions[0];
|
const buildList = () => {
|
||||||
const otherSessions = deviceSessions.slice(1);
|
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 createSessionItem = (session, isCurrent) => {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
@ -48,91 +55,70 @@ export function render({ navigate }) {
|
|||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div class="row" style="align-items:flex-start;">
|
<div class="row" style="align-items:flex-start;">
|
||||||
<div class="stack" style="gap:4px; text-align:left;">
|
<div class="stack" style="gap:4px; text-align:left;">
|
||||||
<strong>${session.clientInfoFromClient}</strong>
|
<strong>${session.clientInfoFromClient || 'unknown client'}</strong>
|
||||||
<span class="meta-muted">${session.geo}</span>
|
<span class="meta-muted">${session.geo || 'unknown'}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="meta-muted">${formatSessionTime(session.lastAuthenticatedAtMs)}</span>
|
<span class="meta-muted">${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>
|
||||||
</div>
|
</div>
|
||||||
${
|
${isCurrent ? '<div><span class="session-current-badge">Текущий сеанс</span></div>' : ''}
|
||||||
isCurrent
|
|
||||||
? '<div><span class="session-current-badge">Текущий сеанс</span></div>'
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
item.addEventListener('click', () => navigate(`device-session-view/${session.sessionId}`));
|
item.addEventListener('click', () => navigate(`device-session-view/${session.sessionId}`));
|
||||||
return item;
|
return item;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
const empty = document.createElement('p');
|
||||||
|
empty.className = 'meta-muted';
|
||||||
|
empty.textContent = 'Активные сессии не найдены.';
|
||||||
|
sessionsBlock.append(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentMenu = document.createElement('div');
|
const currentMenu = document.createElement('div');
|
||||||
currentMenu.className = 'stack';
|
currentMenu.className = 'stack';
|
||||||
currentMenu.innerHTML = '<p class="meta-muted">Текущий сеанс</p>';
|
currentMenu.innerHTML = '<p class="meta-muted">Текущий сеанс</p>';
|
||||||
currentMenu.append(createSessionItem(currentSession, true));
|
currentMenu.append(createSessionItem(current, true));
|
||||||
|
|
||||||
const endCurrentSessionBtn = document.createElement('button');
|
const endCurrentSessionBtn = document.createElement('button');
|
||||||
endCurrentSessionBtn.className = 'text-btn';
|
endCurrentSessionBtn.className = 'text-btn';
|
||||||
endCurrentSessionBtn.type = 'button';
|
endCurrentSessionBtn.type = 'button';
|
||||||
endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
|
endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
|
||||||
|
endCurrentSessionBtn.addEventListener('click', () => {
|
||||||
|
terminateCurrentSession();
|
||||||
|
navigate('start-view');
|
||||||
|
});
|
||||||
currentMenu.append(endCurrentSessionBtn);
|
currentMenu.append(endCurrentSessionBtn);
|
||||||
|
|
||||||
const othersMenu = document.createElement('div');
|
const othersMenu = document.createElement('div');
|
||||||
othersMenu.className = 'stack';
|
othersMenu.className = 'stack';
|
||||||
othersMenu.innerHTML = '<p class="meta-muted">Остальные активные сеансы</p>';
|
othersMenu.innerHTML = '<p class="meta-muted">Остальные активные сеансы</p>';
|
||||||
|
|
||||||
if (otherSessions.length === 0) {
|
if (others.length === 0) {
|
||||||
const empty = document.createElement('p');
|
const empty = document.createElement('p');
|
||||||
empty.className = 'meta-muted';
|
empty.className = 'meta-muted';
|
||||||
empty.textContent = 'Других активных сеансов нет.';
|
empty.textContent = 'Других активных сеансов нет.';
|
||||||
othersMenu.append(empty);
|
othersMenu.append(empty);
|
||||||
} else {
|
} else {
|
||||||
otherSessions.forEach((session) => {
|
others.forEach((session) => {
|
||||||
othersMenu.append(createSessionItem(session, false));
|
othersMenu.append(createSessionItem(session, false));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmModal = document.createElement('div');
|
|
||||||
confirmModal.className = 'modal-shell';
|
|
||||||
confirmModal.hidden = true;
|
|
||||||
confirmModal.innerHTML = `
|
|
||||||
<div class="modal-backdrop" data-close="true"></div>
|
|
||||||
<div class="modal-dialog card" role="dialog" aria-modal="true" tabindex="-1">
|
|
||||||
<p>Завершить текущую сессию?</p>
|
|
||||||
<div class="auth-footer-actions">
|
|
||||||
<button class="primary-btn" type="button" id="confirm-end-yes">Да</button>
|
|
||||||
<button class="ghost-btn" type="button" data-close="true">Нет</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const openModal = () => {
|
|
||||||
confirmModal.hidden = false;
|
|
||||||
confirmModal.querySelector('.modal-dialog').focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
confirmModal.addEventListener('keydown', (event) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
sessionsBlock.append(currentMenu, othersMenu);
|
sessionsBlock.append(currentMenu, othersMenu);
|
||||||
screen.append(actions, sessionsBlock, confirmModal);
|
};
|
||||||
|
|
||||||
|
actions.querySelector('#reload-sessions-btn').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await refreshSessions();
|
||||||
|
buildList();
|
||||||
|
setAuthInfo('Список сессий обновлён.');
|
||||||
|
} catch (error) {
|
||||||
|
setAuthError(error.message);
|
||||||
|
window.alert(error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
buildList();
|
||||||
|
screen.append(actions, sessionsBlock);
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,14 @@
|
|||||||
import { renderHeader } from '../components/header.js?v=20260327192619';
|
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 };
|
export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false };
|
||||||
|
|
||||||
@ -7,6 +16,8 @@ export function render({ navigate }) {
|
|||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack';
|
||||||
|
|
||||||
|
clearAuthMessages();
|
||||||
|
|
||||||
const form = document.createElement('div');
|
const form = document.createElement('div');
|
||||||
form.className = 'card stack';
|
form.className = 'card stack';
|
||||||
|
|
||||||
@ -22,16 +33,9 @@ export function render({ navigate }) {
|
|||||||
passwordInput.value = state.loginDraft.password;
|
passwordInput.value = state.loginDraft.password;
|
||||||
passwordInput.placeholder = 'Введите пароль';
|
passwordInput.placeholder = 'Введите пароль';
|
||||||
|
|
||||||
const advanced = document.createElement('label');
|
const hint = document.createElement('p');
|
||||||
advanced.className = 'checkbox-row';
|
hint.className = 'meta-muted';
|
||||||
advanced.innerHTML = `<input type="checkbox" /> <span>Расширенные настройки</span>`;
|
hint.textContent = 'Root/dev/bch ключи вычисляются из пароля через SHA-256, storagePwd каждый вход приходит с сервера.';
|
||||||
const advancedInput = advanced.querySelector('input');
|
|
||||||
advancedInput.addEventListener('change', () => {
|
|
||||||
if (advancedInput.checked) {
|
|
||||||
window.alert('Расширенные настройки в стартовой версии приложения пока не используются.');
|
|
||||||
advancedInput.checked = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
<label class="stack"><span class="field-label">Логин</span></label>
|
<label class="stack"><span class="field-label">Логин</span></label>
|
||||||
@ -39,7 +43,7 @@ export function render({ navigate }) {
|
|||||||
`;
|
`;
|
||||||
form.children[0].append(loginInput);
|
form.children[0].append(loginInput);
|
||||||
form.children[1].append(passwordInput);
|
form.children[1].append(passwordInput);
|
||||||
form.append(advanced);
|
form.append(hint);
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'auth-footer-actions';
|
actions.className = 'auth-footer-actions';
|
||||||
@ -54,10 +58,35 @@ export function render({ navigate }) {
|
|||||||
enterButton.className = 'primary-btn';
|
enterButton.className = 'primary-btn';
|
||||||
enterButton.type = 'button';
|
enterButton.type = 'button';
|
||||||
enterButton.textContent = 'Войти';
|
enterButton.textContent = 'Войти';
|
||||||
enterButton.addEventListener('click', () => {
|
enterButton.addEventListener('click', async () => {
|
||||||
state.loginDraft.login = loginInput.value;
|
state.loginDraft.login = loginInput.value.trim();
|
||||||
state.loginDraft.password = passwordInput.value;
|
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.login(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);
|
actions.append(backButton, enterButton);
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
import { renderHeader } from '../components/header.js?v=20260327192619';
|
import { renderHeader } from '../components/header.js?v=20260327192619';
|
||||||
import { state } from '../state.js?v=20260327192619';
|
import {
|
||||||
|
authService,
|
||||||
|
clearAuthMessages,
|
||||||
|
setAuthBusy,
|
||||||
|
setAuthError,
|
||||||
|
setAuthInfo,
|
||||||
|
state,
|
||||||
|
} from '../state.js?v=20260327192619';
|
||||||
|
|
||||||
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
||||||
|
|
||||||
@ -7,6 +14,8 @@ export function render({ navigate }) {
|
|||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack';
|
||||||
|
|
||||||
|
clearAuthMessages();
|
||||||
|
|
||||||
const form = document.createElement('div');
|
const form = document.createElement('div');
|
||||||
form.className = 'card stack';
|
form.className = 'card stack';
|
||||||
|
|
||||||
@ -22,16 +31,55 @@ export function render({ navigate }) {
|
|||||||
passwordInput.value = state.registrationDraft.password;
|
passwordInput.value = state.registrationDraft.password;
|
||||||
passwordInput.placeholder = 'Введите пароль';
|
passwordInput.placeholder = 'Введите пароль';
|
||||||
|
|
||||||
const advanced = document.createElement('label');
|
const statusText = document.createElement('p');
|
||||||
advanced.className = 'checkbox-row';
|
statusText.className = 'meta-muted';
|
||||||
advanced.innerHTML = `<input type="checkbox" /> <span>Расширенные настройки</span>`;
|
statusText.textContent = 'Проверка логина: не выполнена';
|
||||||
const advancedInput = advanced.querySelector('input');
|
|
||||||
advancedInput.addEventListener('change', () => {
|
const checkButton = document.createElement('button');
|
||||||
if (advancedInput.checked) {
|
checkButton.className = 'ghost-btn';
|
||||||
window.alert('Расширенные настройки в стартовой версии не работают и не будут работать.');
|
checkButton.type = 'button';
|
||||||
advancedInput.checked = false;
|
checkButton.textContent = 'Проверить логин';
|
||||||
|
|
||||||
|
const saveRootRow = document.createElement('label');
|
||||||
|
saveRootRow.className = 'checkbox-row';
|
||||||
|
saveRootRow.innerHTML = `<input type="checkbox" ${state.keyStorage.saveRoot ? 'checked' : ''} /> <span>Сохранить root key</span>`;
|
||||||
|
const saveRootInput = saveRootRow.querySelector('input');
|
||||||
|
|
||||||
|
const saveBchRow = document.createElement('label');
|
||||||
|
saveBchRow.className = 'checkbox-row';
|
||||||
|
saveBchRow.innerHTML = `<input type="checkbox" ${state.keyStorage.saveBlockchain ? 'checked' : ''} /> <span>Сохранить blockchain key</span>`;
|
||||||
|
const saveBchInput = saveBchRow.querySelector('input');
|
||||||
|
|
||||||
|
const saveDevRow = document.createElement('label');
|
||||||
|
saveDevRow.className = 'checkbox-row';
|
||||||
|
saveDevRow.innerHTML = '<input type="checkbox" checked disabled /> <span>device key сохраняется всегда</span>';
|
||||||
|
|
||||||
|
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 = `
|
form.innerHTML = `
|
||||||
<label class="stack"><span class="field-label">Логин</span></label>
|
<label class="stack"><span class="field-label">Логин</span></label>
|
||||||
@ -39,7 +87,7 @@ export function render({ navigate }) {
|
|||||||
`;
|
`;
|
||||||
form.children[0].append(loginInput);
|
form.children[0].append(loginInput);
|
||||||
form.children[1].append(passwordInput);
|
form.children[1].append(passwordInput);
|
||||||
form.append(advanced);
|
form.append(checkButton, statusText, saveRootRow, saveBchRow, saveDevRow);
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'auth-footer-actions';
|
actions.className = 'auth-footer-actions';
|
||||||
@ -54,10 +102,52 @@ export function render({ navigate }) {
|
|||||||
nextButton.className = 'primary-btn';
|
nextButton.className = 'primary-btn';
|
||||||
nextButton.type = 'button';
|
nextButton.type = 'button';
|
||||||
nextButton.textContent = 'Далее';
|
nextButton.textContent = 'Далее';
|
||||||
nextButton.addEventListener('click', () => {
|
nextButton.addEventListener('click', async () => {
|
||||||
state.registrationDraft.login = loginInput.value;
|
const isFree = await runAvailabilityCheck();
|
||||||
|
if (!isFree) {
|
||||||
|
setAuthError('Выберите свободный логин');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.registrationDraft.login = loginInput.value.trim();
|
||||||
state.registrationDraft.password = passwordInput.value;
|
state.registrationDraft.password = passwordInput.value;
|
||||||
navigate('registration-payment-view');
|
state.keyStorage.saveRoot = saveRootInput.checked;
|
||||||
|
state.keyStorage.saveBlockchain = saveBchInput.checked;
|
||||||
|
state.keyStorage.saveDevice = true;
|
||||||
|
|
||||||
|
if (!state.registrationDraft.password) {
|
||||||
|
window.alert('Введите пароль');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthBusy(true);
|
||||||
|
nextButton.disabled = true;
|
||||||
|
nextButton.textContent = 'Создание...';
|
||||||
|
setAuthError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authService.reconnect(state.entrySettings.shineServer);
|
||||||
|
const result = await authService.register(
|
||||||
|
state.registrationDraft.login,
|
||||||
|
state.registrationDraft.password,
|
||||||
|
{
|
||||||
|
saveRoot: state.keyStorage.saveRoot,
|
||||||
|
saveBlockchain: state.keyStorage.saveBlockchain,
|
||||||
|
saveDevice: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
state.registrationDraft.sessionId = result.sessionId;
|
||||||
|
state.registrationDraft.storagePwd = result.storagePwd;
|
||||||
|
setAuthInfo(`Пользователь ${result.login} зарегистрирован`);
|
||||||
|
navigate('registration-keys-view');
|
||||||
|
} catch (error) {
|
||||||
|
setAuthError(error.message);
|
||||||
|
window.alert(error.message);
|
||||||
|
} finally {
|
||||||
|
setAuthBusy(false);
|
||||||
|
nextButton.disabled = false;
|
||||||
|
nextButton.textContent = 'Далее';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.append(backButton, nextButton);
|
actions.append(backButton, nextButton);
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import { renderHeader } from '../components/header.js?v=20260327192619';
|
import { renderHeader } from '../components/header.js?v=20260327192619';
|
||||||
import { authorizeSession, state } from '../state.js?v=20260327192619';
|
import {
|
||||||
|
authorizeSession,
|
||||||
|
refreshSessions,
|
||||||
|
setAuthError,
|
||||||
|
setAuthInfo,
|
||||||
|
state,
|
||||||
|
} from '../state.js?v=20260327192619';
|
||||||
|
|
||||||
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
|
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
|
||||||
|
|
||||||
@ -15,47 +21,23 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const title = document.createElement('p');
|
const title = document.createElement('p');
|
||||||
title.className = 'auth-copy';
|
title.className = 'auth-copy';
|
||||||
title.textContent = `Поздравляю, ваш логин ${displayLogin} зарегистрирован.`;
|
title.textContent = `Поздравляю, логин ${displayLogin} зарегистрирован.`;
|
||||||
|
|
||||||
const question = document.createElement('p');
|
const question = document.createElement('p');
|
||||||
question.className = 'auth-copy';
|
question.className = 'auth-copy';
|
||||||
question.textContent = 'Какие ключи вы хотите сохранить на этом устройстве?';
|
question.textContent = 'Ключи считаются из пароля (SHA-256 + суффиксы root.key/dev.key/bch.key). В IndexedDB сохраняются только выбранные ключи и всегда device key.';
|
||||||
|
|
||||||
const rootToggle = document.createElement('input');
|
|
||||||
rootToggle.type = 'checkbox';
|
|
||||||
rootToggle.checked = state.keyStorage.saveRoot;
|
|
||||||
|
|
||||||
const blockchainToggle = document.createElement('input');
|
|
||||||
blockchainToggle.type = 'checkbox';
|
|
||||||
blockchainToggle.checked = state.keyStorage.saveBlockchain;
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
const rootRow = document.createElement('label');
|
const rootRow = document.createElement('label');
|
||||||
rootRow.className = 'checkbox-row';
|
rootRow.className = 'checkbox-row';
|
||||||
rootRow.append(rootToggle, document.createTextNode('root key'));
|
rootRow.innerHTML = `<input type="checkbox" ${state.keyStorage.saveRoot ? 'checked' : ''} disabled /> <span>root key</span>`;
|
||||||
|
|
||||||
const blockchainRow = document.createElement('label');
|
const blockchainRow = document.createElement('label');
|
||||||
blockchainRow.className = 'checkbox-row';
|
blockchainRow.className = 'checkbox-row';
|
||||||
blockchainRow.append(blockchainToggle, document.createTextNode('blockchain key'));
|
blockchainRow.innerHTML = `<input type="checkbox" ${state.keyStorage.saveBlockchain ? 'checked' : ''} disabled /> <span>blockchain key</span>`;
|
||||||
|
|
||||||
const deviceRow = document.createElement('label');
|
const deviceRow = document.createElement('label');
|
||||||
deviceRow.className = 'checkbox-row';
|
deviceRow.className = 'checkbox-row';
|
||||||
deviceRow.append(deviceToggle, document.createTextNode('device key'));
|
deviceRow.innerHTML = '<input type="checkbox" checked disabled /> <span>device key</span>';
|
||||||
|
|
||||||
card.append(title, question, rootRow, deviceRow, blockchainRow);
|
card.append(title, question, rootRow, deviceRow, blockchainRow);
|
||||||
|
|
||||||
@ -72,9 +54,20 @@ export function render({ navigate }) {
|
|||||||
okButton.className = 'primary-btn';
|
okButton.className = 'primary-btn';
|
||||||
okButton.type = 'button';
|
okButton.type = 'button';
|
||||||
okButton.textContent = 'OK';
|
okButton.textContent = 'OK';
|
||||||
okButton.addEventListener('click', () => {
|
okButton.addEventListener('click', async () => {
|
||||||
authorizeSession();
|
try {
|
||||||
|
authorizeSession({
|
||||||
|
login: state.registrationDraft.login,
|
||||||
|
sessionId: state.registrationDraft.sessionId,
|
||||||
|
storagePwd: state.registrationDraft.storagePwd,
|
||||||
|
});
|
||||||
|
await refreshSessions();
|
||||||
|
setAuthInfo('Регистрация завершена, список сессий загружен.');
|
||||||
navigate('profile-view');
|
navigate('profile-view');
|
||||||
|
} catch (error) {
|
||||||
|
setAuthError(error.message);
|
||||||
|
window.alert(error.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.append(cancelButton, okButton);
|
actions.append(cancelButton, okButton);
|
||||||
@ -82,7 +75,7 @@ export function render({ navigate }) {
|
|||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Сохранение ключей',
|
title: 'Сохранение ключей',
|
||||||
leftAction: { label: '←', onClick: () => navigate('registration-payment-view') },
|
leftAction: { label: '←', onClick: () => navigate('start-view') },
|
||||||
}),
|
}),
|
||||||
card,
|
card,
|
||||||
actions,
|
actions,
|
||||||
|
|||||||
209
shine-UI/js/services/auth-service.js
Normal file
209
shine-UI/js/services/auth-service.js
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { WsJsonClient } from './ws-client.js?v=20260327192619';
|
||||||
|
import {
|
||||||
|
deriveEd25519FromPassword,
|
||||||
|
exportEd25519PublicKeyB64,
|
||||||
|
exportPkcs8B64,
|
||||||
|
generateEd25519Pair,
|
||||||
|
importPkcs8Ed25519,
|
||||||
|
randomBase64,
|
||||||
|
signBase64,
|
||||||
|
} from './crypto-utils.js?v=20260327192619';
|
||||||
|
import {
|
||||||
|
loadEncryptedUserSecrets,
|
||||||
|
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 register(login, password, saveOptions = { saveRoot: true, saveBlockchain: true, saveDevice: true }) {
|
||||||
|
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 storagePwd = randomBase64(32);
|
||||||
|
|
||||||
|
const rootPair = await deriveEd25519FromPassword(password, 'root.key');
|
||||||
|
const blockchainPair = await deriveEd25519FromPassword(password, 'bch.key');
|
||||||
|
const devicePair = await deriveEd25519FromPassword(password, 'dev.key');
|
||||||
|
|
||||||
|
const sessionPair = await generateEd25519Pair();
|
||||||
|
const sessionKeyPub = await exportEd25519PublicKeyB64(sessionPair.publicKey);
|
||||||
|
const sessionKey = `ed25519/${sessionKeyPub}`;
|
||||||
|
|
||||||
|
const addResp = await this.ws.request('AddUser', {
|
||||||
|
login: cleanLogin,
|
||||||
|
blockchainName: `${cleanLogin}-${BCH_SUFFIX}`,
|
||||||
|
solanaKey: rootPair.publicKeyB64,
|
||||||
|
blockchainKey: blockchainPair.publicKeyB64,
|
||||||
|
deviceKey: devicePair.publicKeyB64,
|
||||||
|
bchLimit: 1000000,
|
||||||
|
});
|
||||||
|
if (addResp.status !== 200) throw opError('AddUser', addResp);
|
||||||
|
|
||||||
|
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(devicePair.privateKey, preimage);
|
||||||
|
|
||||||
|
const createResp = await this.ws.request('CreateAuthSession', {
|
||||||
|
login: cleanLogin,
|
||||||
|
storagePwd,
|
||||||
|
sessionKey,
|
||||||
|
timeMs,
|
||||||
|
authNonce,
|
||||||
|
deviceKey: 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');
|
||||||
|
|
||||||
|
const secrets = {
|
||||||
|
deviceKey: devicePair.privatePkcs8B64,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (saveOptions.saveRoot) {
|
||||||
|
secrets.rootKey = rootPair.privatePkcs8B64;
|
||||||
|
}
|
||||||
|
if (saveOptions.saveBlockchain) {
|
||||||
|
secrets.blockchainKey = blockchainPair.privatePkcs8B64;
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveEncryptedUserSecrets(cleanLogin, storagePwd, secrets);
|
||||||
|
|
||||||
|
await saveSessionMaterial(cleanLogin, {
|
||||||
|
sessionId,
|
||||||
|
sessionKey,
|
||||||
|
sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
login: cleanLogin,
|
||||||
|
sessionId,
|
||||||
|
storagePwd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(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 sessionMaterial = await loadSessionMaterial(cleanLogin);
|
||||||
|
if (!sessionMaterial?.sessionId || !sessionMaterial?.sessionKey || !sessionMaterial?.sessionPrivPkcs8) {
|
||||||
|
throw new Error('На устройстве отсутствует ключ сессии. Нужна регистрация на этом устройстве.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await deriveEd25519FromPassword(password, 'root.key');
|
||||||
|
await deriveEd25519FromPassword(password, 'bch.key');
|
||||||
|
await deriveEd25519FromPassword(password, 'dev.key');
|
||||||
|
|
||||||
|
const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8);
|
||||||
|
|
||||||
|
const challengeResp = await this.ws.request('SessionChallenge', { sessionId: sessionMaterial.sessionId });
|
||||||
|
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:${sessionMaterial.sessionId}:${timeMs}:${nonce}`;
|
||||||
|
const signatureB64 = await signBase64(privateKey, preimage);
|
||||||
|
|
||||||
|
const loginResp = await this.ws.request('SessionLogin', {
|
||||||
|
sessionId: sessionMaterial.sessionId,
|
||||||
|
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');
|
||||||
|
|
||||||
|
await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||||
|
|
||||||
|
return {
|
||||||
|
login: cleanLogin,
|
||||||
|
sessionId: sessionMaterial.sessionId,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
150
shine-UI/js/services/crypto-utils.js
Normal file
150
shine-UI/js/services/crypto-utils.js
Normal file
@ -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));
|
||||||
|
}
|
||||||
78
shine-UI/js/services/key-vault.js
Normal file
78
shine-UI/js/services/key-vault.js
Normal file
@ -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);
|
||||||
|
}
|
||||||
112
shine-UI/js/services/ws-client.js
Normal file
112
shine-UI/js/services/ws-client.js
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,19 +1,52 @@
|
|||||||
import { chatMessages, wallet } from './mock-data.js?v=20260327192619';
|
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 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 = {
|
export const state = {
|
||||||
chats: clone(chatMessages),
|
chats: clone(chatMessages),
|
||||||
notificationsTab: 'replies',
|
notificationsTab: 'replies',
|
||||||
pageLabelCollapsed: false,
|
pageLabelCollapsed: false,
|
||||||
session: {
|
session: {
|
||||||
isAuthorized: false,
|
isAuthorized: Boolean(storedSession?.isAuthorized),
|
||||||
|
login: storedSession?.login || '',
|
||||||
|
sessionId: storedSession?.sessionId || '',
|
||||||
|
storagePwdInMemory: '',
|
||||||
},
|
},
|
||||||
startHint: '',
|
startHint: '',
|
||||||
entrySettings: {
|
entrySettings: {
|
||||||
language: 'ru',
|
language: 'ru',
|
||||||
solanaServer: 'https://api.mainnet-beta.solana.com',
|
solanaServer: 'https://api.mainnet-beta.solana.com',
|
||||||
shineServer: 'https://demo.shine.local',
|
shineServer: 'wss://shineup.me/ws',
|
||||||
arweaveServer: 'https://arweave.net',
|
arweaveServer: 'https://arweave.net',
|
||||||
statuses: {
|
statuses: {
|
||||||
solanaServer: 'idle',
|
solanaServer: 'idle',
|
||||||
@ -24,6 +57,8 @@ export const state = {
|
|||||||
registrationDraft: {
|
registrationDraft: {
|
||||||
login: '',
|
login: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
sessionId: '',
|
||||||
|
storagePwd: '',
|
||||||
},
|
},
|
||||||
loginDraft: {
|
loginDraft: {
|
||||||
login: '',
|
login: '',
|
||||||
@ -34,10 +69,10 @@ export const state = {
|
|||||||
balanceSOL: '0.0068',
|
balanceSOL: '0.0068',
|
||||||
},
|
},
|
||||||
keyStorage: {
|
keyStorage: {
|
||||||
rootKey: 'RK-4Q8N-1SZP-71LM-AUTH-ROOT',
|
rootKey: 'Ключ root хранится в зашифрованном виде',
|
||||||
blockchainKey: 'BK-SOL-19F2-CHAIN-ACCESS',
|
blockchainKey: 'Ключ blockchain хранится в зашифрованном виде',
|
||||||
deviceKey: 'DK-LOCAL-82XA-DEVICE-SIGN',
|
deviceKey: 'Ключ device хранится в зашифрованном виде',
|
||||||
saveRoot: false,
|
saveRoot: true,
|
||||||
saveBlockchain: true,
|
saveBlockchain: true,
|
||||||
saveDevice: true,
|
saveDevice: true,
|
||||||
},
|
},
|
||||||
@ -46,8 +81,16 @@ export const state = {
|
|||||||
blockchain: true,
|
blockchain: true,
|
||||||
device: true,
|
device: true,
|
||||||
},
|
},
|
||||||
|
authUi: {
|
||||||
|
busy: false,
|
||||||
|
error: '',
|
||||||
|
info: '',
|
||||||
|
},
|
||||||
|
sessions: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const authService = new AuthService(state.entrySettings.shineServer);
|
||||||
|
|
||||||
export function getChatMessages(chatId) {
|
export function getChatMessages(chatId) {
|
||||||
if (!state.chats[chatId]) {
|
if (!state.chats[chatId]) {
|
||||||
state.chats[chatId] = [];
|
state.chats[chatId] = [];
|
||||||
@ -73,12 +116,12 @@ export function checkServerAvailability(address) {
|
|||||||
const normalized = address.trim().toLowerCase();
|
const normalized = address.trim().toLowerCase();
|
||||||
if (!normalized) return 'unavailable';
|
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);
|
const blockedWord = /(offline|down|fail|bad|broken|invalid)/i.test(normalized);
|
||||||
return looksLikeUrl && !blockedWord ? 'available' : 'unavailable';
|
return looksLikeUrl && !blockedWord ? 'available' : 'unavailable';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveEntrySettings(nextSettings) {
|
export async function saveEntrySettings(nextSettings) {
|
||||||
state.entrySettings = {
|
state.entrySettings = {
|
||||||
...state.entrySettings,
|
...state.entrySettings,
|
||||||
...nextSettings,
|
...nextSettings,
|
||||||
@ -87,6 +130,7 @@ export function saveEntrySettings(nextSettings) {
|
|||||||
...(nextSettings.statuses || {}),
|
...(nextSettings.statuses || {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
await authService.reconnect(state.entrySettings.shineServer);
|
||||||
state.startHint = 'Настройки входа сохранены, адреса серверов обновлены.';
|
state.startHint = 'Настройки входа сохранены, адреса серверов обновлены.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,13 +138,48 @@ export function clearStartHint() {
|
|||||||
state.startHint = '';
|
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.isAuthorized = true;
|
||||||
|
state.session.login = login;
|
||||||
|
state.session.sessionId = sessionId;
|
||||||
|
state.session.storagePwdInMemory = storagePwd;
|
||||||
|
persistSession({
|
||||||
|
isAuthorized: true,
|
||||||
|
login,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
state.startHint = '';
|
state.startHint = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshSessions() {
|
||||||
|
state.sessions = await authService.listSessions();
|
||||||
|
return state.sessions;
|
||||||
|
}
|
||||||
|
|
||||||
export function terminateCurrentSession() {
|
export function terminateCurrentSession() {
|
||||||
state.session.isAuthorized = false;
|
state.session.isAuthorized = false;
|
||||||
|
state.session.login = '';
|
||||||
|
state.session.sessionId = '';
|
||||||
|
state.session.storagePwdInMemory = '';
|
||||||
|
state.sessions = [];
|
||||||
|
clearStoredSession();
|
||||||
state.startHint = '';
|
state.startHint = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user