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 {
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);
await refreshSessions();
setAuthInfo('Успешный вход выполнен.');

View File

@ -1,12 +1,5 @@
import { renderHeader } from '../components/header.js?v=20260327192619';
import {
authService,
clearAuthMessages,
setAuthBusy,
setAuthError,
setAuthInfo,
state,
} from '../state.js?v=20260327192619';
import { authService, state } from '../state.js?v=20260327192619';
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
@ -40,20 +33,6 @@ export function render({ navigate }) {
checkButton.type = 'button';
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) {
@ -87,7 +66,7 @@ export function render({ navigate }) {
`;
form.children[0].append(loginInput);
form.children[1].append(passwordInput);
form.append(checkButton, statusText, saveRootRow, saveBchRow, saveDevRow);
form.append(checkButton, statusText);
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
@ -105,49 +84,19 @@ export function render({ navigate }) {
nextButton.addEventListener('click', async () => {
const isFree = await runAvailabilityCheck();
if (!isFree) {
setAuthError('Выберите свободный логин');
window.alert('Выберите свободный логин');
return;
}
state.registrationDraft.login = loginInput.value.trim();
state.registrationDraft.password = passwordInput.value;
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 = 'Далее';
}
navigate('registration-payment-view');
});
actions.append(backButton, nextButton);

View File

@ -1,5 +1,6 @@
import { renderHeader } from '../components/header.js?v=20260327192619';
import {
authService,
authorizeSession,
refreshSessions,
setAuthError,
@ -21,11 +22,24 @@ 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 = 'Ключи считаются из пароля (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');
rootRow.className = 'checkbox-row';
@ -37,7 +51,7 @@ export function render({ navigate }) {
const deviceRow = document.createElement('label');
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);
@ -56,13 +70,34 @@ export function render({ navigate }) {
okButton.textContent = 'OK';
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('Регистрация завершена, список сессий загружен.');
setAuthInfo('Ключи сохранены, регистрация завершена.');
navigate('profile-view');
} catch (error) {
setAuthError(error.message);

View File

@ -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', () => {
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 = `

View File

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

View File

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