Merge pull request #4 from ai5590/codex/connect-ui-client-to-server-for-authentication-xqvn1u

Add AuthService + crypto/key-vault + WS client and integrate real auth/session flows into UI
This commit is contained in:
ai5590 2026-03-30 02:16:35 +03:00 committed by GitHub
commit 52fa631733
6 changed files with 129 additions and 151 deletions

View File

@ -74,7 +74,7 @@ export function render({ navigate }) {
try { try {
await authService.reconnect(state.entrySettings.shineServer); await authService.reconnect(state.entrySettings.shineServer);
const result = await authService.login(state.loginDraft.login, state.loginDraft.password); const result = await authService.createSessionForExistingUser(state.loginDraft.login, state.loginDraft.password);
authorizeSession(result); authorizeSession(result);
await refreshSessions(); await refreshSessions();
setAuthInfo('Успешный вход выполнен.'); setAuthInfo('Успешный вход выполнен.');

View File

@ -1,12 +1,5 @@
import { renderHeader } from '../components/header.js?v=20260327192619'; import { renderHeader } from '../components/header.js?v=20260327192619';
import { import { authService, state } from '../state.js?v=20260327192619';
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 };
@ -40,20 +33,6 @@ export function render({ navigate }) {
checkButton.type = 'button'; checkButton.type = 'button';
checkButton.textContent = 'Проверить логин'; 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() { async function runAvailabilityCheck() {
const login = loginInput.value.trim(); const login = loginInput.value.trim();
if (!login) { if (!login) {
@ -87,7 +66,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(checkButton, statusText, saveRootRow, saveBchRow, saveDevRow); form.append(checkButton, statusText);
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'auth-footer-actions'; actions.className = 'auth-footer-actions';
@ -105,49 +84,19 @@ export function render({ navigate }) {
nextButton.addEventListener('click', async () => { nextButton.addEventListener('click', async () => {
const isFree = await runAvailabilityCheck(); const isFree = await runAvailabilityCheck();
if (!isFree) { if (!isFree) {
setAuthError('Выберите свободный логин'); window.alert('Выберите свободный логин');
return; return;
} }
state.registrationDraft.login = loginInput.value.trim(); state.registrationDraft.login = loginInput.value.trim();
state.registrationDraft.password = passwordInput.value; state.registrationDraft.password = passwordInput.value;
state.keyStorage.saveRoot = saveRootInput.checked;
state.keyStorage.saveBlockchain = saveBchInput.checked;
state.keyStorage.saveDevice = true;
if (!state.registrationDraft.password) { if (!state.registrationDraft.password) {
window.alert('Введите пароль'); window.alert('Введите пароль');
return; return;
} }
setAuthBusy(true); navigate('registration-payment-view');
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);

View File

@ -1,5 +1,6 @@
import { renderHeader } from '../components/header.js?v=20260327192619'; import { renderHeader } from '../components/header.js?v=20260327192619';
import { import {
authService,
authorizeSession, authorizeSession,
refreshSessions, refreshSessions,
setAuthError, setAuthError,
@ -21,11 +22,24 @@ 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 = 'Ключи считаются из пароля (SHA-256 + суффиксы root.key/dev.key/bch.key). В IndexedDB сохраняются только выбранные ключи и всегда device key.'; question.textContent = 'Какие ключи сохранить в зашифрованном контейнере IndexedDB?';
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 = true;
deviceToggle.disabled = true;
const rootRow = document.createElement('label'); const rootRow = document.createElement('label');
rootRow.className = 'checkbox-row'; rootRow.className = 'checkbox-row';
@ -37,7 +51,7 @@ export function render({ navigate }) {
const deviceRow = document.createElement('label'); const deviceRow = document.createElement('label');
deviceRow.className = 'checkbox-row'; deviceRow.className = 'checkbox-row';
deviceRow.innerHTML = '<input type="checkbox" checked disabled /> <span>device key</span>'; deviceRow.append(deviceToggle, document.createTextNode('device key (всегда)'));
card.append(title, question, rootRow, deviceRow, blockchainRow); card.append(title, question, rootRow, deviceRow, blockchainRow);
@ -56,13 +70,34 @@ export function render({ navigate }) {
okButton.textContent = 'OK'; okButton.textContent = 'OK';
okButton.addEventListener('click', async () => { okButton.addEventListener('click', async () => {
try { 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({ authorizeSession({
login: state.registrationDraft.login, login: state.registrationDraft.login,
sessionId: state.registrationDraft.sessionId, sessionId: state.registrationDraft.sessionId,
storagePwd: state.registrationDraft.storagePwd, storagePwd: state.registrationDraft.storagePwd,
}); });
await refreshSessions(); await refreshSessions();
setAuthInfo('Регистрация завершена, список сессий загружен.'); setAuthInfo('Ключи сохранены, регистрация завершена.');
navigate('profile-view'); navigate('profile-view');
} catch (error) { } catch (error) {
setAuthError(error.message); setAuthError(error.message);

View File

@ -1,5 +1,11 @@
import { renderHeader } from '../components/header.js?v=20260327192619'; 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 }; export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
@ -66,8 +72,28 @@ export function render({ navigate }) {
submitButton.className = 'primary-btn'; submitButton.className = 'primary-btn';
submitButton.type = 'button'; submitButton.type = 'button';
submitButton.textContent = 'Зарегистрироваться'; submitButton.textContent = 'Зарегистрироваться';
submitButton.addEventListener('click', () => { submitButton.addEventListener('click', async () => {
navigate('registration-keys-view'); 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 = ` card.innerHTML = `

View File

@ -4,16 +4,10 @@ import {
exportEd25519PublicKeyB64, exportEd25519PublicKeyB64,
exportPkcs8B64, exportPkcs8B64,
generateEd25519Pair, generateEd25519Pair,
importPkcs8Ed25519,
randomBase64, randomBase64,
signBase64, signBase64,
} from './crypto-utils.js?v=20260327192619'; } from './crypto-utils.js?v=20260327192619';
import { import { saveEncryptedUserSecrets, saveSessionMaterial } from './key-vault.js?v=20260327192619';
loadEncryptedUserSecrets,
loadSessionMaterial,
saveEncryptedUserSecrets,
saveSessionMaterial,
} from './key-vault.js?v=20260327192619';
const BCH_SUFFIX = '001'; const BCH_SUFFIX = '001';
@ -63,33 +57,22 @@ export class AuthService {
return payload.exists !== true; return payload.exists !== true;
} }
async register(login, password, saveOptions = { saveRoot: true, saveBlockchain: true, saveDevice: true }) { async derivePasswordKeyBundle(password) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Введите логин');
if (!password) 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 rootPair = await deriveEd25519FromPassword(password, 'root.key');
const blockchainPair = await deriveEd25519FromPassword(password, 'bch.key'); const blockchainPair = await deriveEd25519FromPassword(password, 'bch.key');
const devicePair = await deriveEd25519FromPassword(password, 'dev.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 sessionPair = await generateEd25519Pair();
const sessionKeyPub = await exportEd25519PublicKeyB64(sessionPair.publicKey); const sessionKeyPub = await exportEd25519PublicKeyB64(sessionPair.publicKey);
const sessionKey = `ed25519/${sessionKeyPub}`; const sessionKey = `ed25519/${sessionKeyPub}`;
const storagePwd = randomBase64(32);
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 }); const challengeResp = await this.ws.request('AuthChallenge', { login: cleanLogin });
if (challengeResp.status !== 200) throw opError('AuthChallenge', challengeResp); if (challengeResp.status !== 200) throw opError('AuthChallenge', challengeResp);
@ -99,7 +82,7 @@ export class AuthService {
const timeMs = Date.now(); const timeMs = Date.now();
const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${sessionKey}:${storagePwd}:${timeMs}:${authNonce}`; const preimage = `AUTH_CREATE_SESSION:${cleanLogin}:${sessionKey}:${storagePwd}:${timeMs}:${authNonce}`;
const signatureB64 = await signBase64(devicePair.privateKey, preimage); const signatureB64 = await signBase64(keyBundle.devicePair.privateKey, preimage);
const createResp = await this.ws.request('CreateAuthSession', { const createResp = await this.ws.request('CreateAuthSession', {
login: cleanLogin, login: cleanLogin,
@ -107,43 +90,52 @@ export class AuthService {
sessionKey, sessionKey,
timeMs, timeMs,
authNonce, authNonce,
deviceKey: devicePair.publicKeyB64, deviceKey: keyBundle.devicePair.publicKeyB64,
signatureB64, signatureB64,
clientInfo: makeClientInfo(), clientInfo: makeClientInfo(),
}); });
if (createResp.status !== 200) throw opError('CreateAuthSession', createResp); if (createResp.status !== 200) throw opError('CreateAuthSession', createResp);
const sessionId = createResp?.payload?.sessionId; const sessionId = createResp?.payload?.sessionId;
if (!sessionId) throw new Error('CreateAuthSession: не вернулся 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 { return {
login: cleanLogin, login: cleanLogin,
sessionId, sessionId,
storagePwd, storagePwd,
sessionMaterial: {
sessionId,
sessionKey,
sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey),
},
}; };
} }
async login(login, password) { 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(); const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Введите логин'); if (!cleanLogin) throw new Error('Введите логин');
if (!password) throw new Error('Введите пароль'); if (!password) throw new Error('Введите пароль');
@ -151,45 +143,19 @@ export class AuthService {
const user = await this.getUser(cleanLogin); const user = await this.getUser(cleanLogin);
if (!user.exists) throw new Error('Пользователь не найден'); if (!user.exists) throw new Error('Пользователь не найден');
const sessionMaterial = await loadSessionMaterial(cleanLogin); const keyBundle = await this.derivePasswordKeyBundle(password);
if (!sessionMaterial?.sessionId || !sessionMaterial?.sessionKey || !sessionMaterial?.sessionPrivPkcs8) { return this.createAuthSession(cleanLogin, keyBundle);
throw new Error('На устройстве отсутствует ключ сессии. Нужна регистрация на этом устройстве.'); }
}
await deriveEd25519FromPassword(password, 'root.key'); async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) {
await deriveEd25519FromPassword(password, 'bch.key'); const secrets = { deviceKey: keyBundle.devicePair.privatePkcs8B64 };
await deriveEd25519FromPassword(password, 'dev.key'); if (saveOptions.saveRoot) secrets.rootKey = keyBundle.rootPair.privatePkcs8B64;
if (saveOptions.saveBlockchain) secrets.blockchainKey = keyBundle.blockchainPair.privatePkcs8B64;
await saveEncryptedUserSecrets(login, storagePwd, secrets);
}
const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8); async persistSessionMaterial(login, sessionMaterial) {
await saveSessionMaterial(login, sessionMaterial);
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() { async listSessions() {

View File

@ -37,7 +37,7 @@ export const state = {
notificationsTab: 'replies', notificationsTab: 'replies',
pageLabelCollapsed: false, pageLabelCollapsed: false,
session: { session: {
isAuthorized: Boolean(storedSession?.isAuthorized), isAuthorized: false,
login: storedSession?.login || '', login: storedSession?.login || '',
sessionId: storedSession?.sessionId || '', sessionId: storedSession?.sessionId || '',
storagePwdInMemory: '', storagePwdInMemory: '',
@ -59,9 +59,11 @@ export const state = {
password: '', password: '',
sessionId: '', sessionId: '',
storagePwd: '', storagePwd: '',
pendingKeyBundle: null,
pendingSessionMaterial: null,
}, },
loginDraft: { loginDraft: {
login: '', login: storedSession?.login || '',
password: '', password: '',
}, },
registrationPayment: { registrationPayment: {