Добавить UI pairing по коду и обновить документацию агента

This commit is contained in:
AidarKC 2026-06-14 20:39:05 +04:00
parent b166013707
commit c681b4d684
13 changed files with 846 additions and 5 deletions

View File

@ -0,0 +1,25 @@
# ui подключение по коду
- краткое описание фичи:
- в UI добавлен новый сценарий подключения устройства через доверенную уже авторизованную сессию пользователя;
- на экране входа появилась кнопка `Войти через другое устройство`;
- на доверённом устройстве в `Подключить устройство` появилась кнопка `Подключить по коду`;
- доверённое устройство может включить pairing-пароль, увидеть заявки, подтвердить подключение только с `device key` или с передачей выбранных ключей.
- что именно проверять:
- на уже авторизованном устройстве включить pairing-пароль;
- на новом устройстве открыть `Войти через другое устройство`, ввести `login + pairing password` и получить 7-значный код;
- на доверённом устройстве открыть `Подключить по коду`, найти заявку по коду и подтвердить её:
- без доп. ключей;
- с передачей выбранных ключей;
- убедиться, что новое устройство реально входит в аккаунт и сохраняет нужные ключи;
- отдельно проверить отклонение заявки и истечение TTL.
- ожидаемый результат:
- новое устройство получает код, доверённое устройство видит ту же заявку и может её подтвердить или отклонить;
- после approve новое устройство автоматически входит в аккаунт;
- в режиме без доп. ключей переносится только `device key`;
- в расширенном режиме переносятся `device key` и отмеченные ключи `blockchain/root`, если они есть на доверённом устройстве.
- статус:
- `pending`

View File

@ -27,6 +27,7 @@
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
- Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history/<username>/`.
- Архив истории после `/new`: `data/history/<username>/archive/`.
- После `/new` для этого же пользователя должен сбрасываться и контекст продолжения Codex-сессии; следующий запрос запускается как новая сессия, не через resume.
- Для просмотра истории игрока открывать файлы в его папке истории по username.
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.

View File

@ -89,7 +89,7 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь
- `/queue` — список задач в очереди.
- `/stop` — остановить текущую задачу.
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
- `/new` — архивировать текущую историю и начать новый диалог.
- `/new` — архивировать текущую историю, сбросить продолжение Codex-сессии для этого пользователя и начать новый диалог.
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой.

View File

@ -1,2 +1,2 @@
client.version=1.2.192
server.version=1.2.181
client.version=1.2.193
server.version=1.2.182

View File

@ -42,6 +42,7 @@ import * as topupView from './pages/topup-view.js';
import * as devnetTopupView from './pages/devnet-topup-view.js';
import * as loginView from './pages/login-view.js';
import * as loginCameraView from './pages/login-camera-view.js';
import * as loginOtherDeviceView from './pages/login-other-device-view.js';
import * as loginPasswordView from './pages/login-password-view.js';
import * as keyStorageView from './pages/key-storage-view.js';
@ -54,6 +55,7 @@ import * as serverSettingsView from './pages/server-settings-view.js';
import * as toolsSettingsView from './pages/tools-settings-view.js';
import * as deviceView from './pages/device-view.js?v=202606131435';
import * as connectDeviceView from './pages/connect-device-view.js';
import * as devicePairingView from './pages/device-pairing-view.js';
import * as deviceQrView from './pages/device-qr-view.js';
import * as deviceCameraView from './pages/device-camera-view.js';
import * as showKeysView from './pages/show-keys-view.js';
@ -85,6 +87,7 @@ const routes = {
'devnet-topup-view': devnetTopupView,
'login-view': loginView,
'login-camera-view': loginCameraView,
'login-other-device-view': loginOtherDeviceView,
'login-password-view': loginPasswordView,
'key-storage-view': keyStorageView,
'profile-view': profileView,
@ -96,6 +99,7 @@ const routes = {
'tools-settings-view': toolsSettingsView,
'device-view': deviceView,
'connect-device-view': connectDeviceView,
'device-pairing-view': devicePairingView,
'device-qr-view': deviceQrView,
'device-camera-view': deviceCameraView,
'show-keys-view': showKeysView,

View File

@ -28,6 +28,7 @@ export function render({ navigate }) {
</div>
<div class="stack">
<button class="primary-btn" type="button" id="open-qr">Показать QR-код для подключения</button>
<button class="ghost-btn" type="button" id="open-pairing">Подключить по коду</button>
<button class="text-btn" type="button" id="open-camera">Подключить через камеру</button>
</div>
`;
@ -37,6 +38,7 @@ export function render({ navigate }) {
const deviceToggle = card.querySelector('#connect-device');
const statusEl = card.querySelector('#connect-keys-status');
const openQrBtn = card.querySelector('#open-qr');
const openPairBtn = card.querySelector('#open-pairing');
deviceToggle.checked = true;
rootToggle.addEventListener('change', () => {
@ -70,7 +72,7 @@ export function render({ navigate }) {
<p class="meta-muted">подключение происходит напрямую через QR</p>
<p class="meta-muted">сервер не используется</p>
<p class="meta-muted">текущая логика: устройство 1 показывает QR, устройство 2 сканирует</p>
<p class="meta-muted">обратный сценарий пока не реализован</p>
<p class="meta-muted">для сценария через сервер используйте кнопку «Подключить по коду»</p>
</div>
<button class="primary-btn" type="button" data-close="true">OK</button>
</div>
@ -87,6 +89,7 @@ export function render({ navigate }) {
card.querySelector('#tech-help').addEventListener('click', openHelp);
card.querySelector('#open-qr').addEventListener('click', () => navigate('device-qr-view'));
card.querySelector('#open-pairing').addEventListener('click', () => navigate('device-pairing-view'));
card.querySelector('#open-camera').addEventListener('click', () => navigate('device-camera-view'));
(async () => {
@ -109,6 +112,7 @@ export function render({ navigate }) {
blockchainToggle.checked = state.deviceConnect.blockchain;
deviceToggle.checked = hasDevice;
openQrBtn.disabled = !hasDevice;
openPairBtn.disabled = !hasDevice;
const available = [
hasDevice ? 'device' : '',
@ -126,6 +130,7 @@ export function render({ navigate }) {
state.deviceConnect.blockchain = false;
state.deviceConnect.device = false;
openQrBtn.disabled = true;
openPairBtn.disabled = true;
statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
}
})();

View File

@ -0,0 +1,321 @@
import { renderHeader } from '../components/header.js';
import {
authService,
refreshSessions,
setAuthError,
setAuthInfo,
state,
} from '../state.js';
import { formatRelativeTime, showToast } from '../services/channels-ux.js';
import {
buildSecretsPayload,
deriveEspPairingPasswordHash,
encryptPairingPayloadForRequester,
} from '../services/device-pairing-service.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'device-pairing-view', title: 'Подключить по коду' };
function setStatus(statusEl, message, kind = 'info') {
statusEl.classList.toggle('is-unavailable', kind === 'error');
statusEl.classList.toggle('is-available', kind !== 'error');
statusEl.textContent = message;
statusEl.style.display = message ? '' : 'none';
}
function normalizeCode(value) {
return String(value || '').replace(/\D+/g, '').slice(0, 7);
}
function buildTransferKeys(savedKeys, { withExtras = false }) {
const keys = {
deviceKey: String(savedKeys?.deviceKey || '').trim(),
blockchainKey: '',
rootKey: '',
};
if (!keys.deviceKey) {
throw new Error('На этом устройстве нет сохранённого device key для передачи.');
}
if (withExtras) {
if (state.deviceConnect.blockchain && savedKeys?.blockchainKey) {
keys.blockchainKey = String(savedKeys.blockchainKey || '').trim();
}
if (state.deviceConnect.root && savedKeys?.rootKey) {
keys.rootKey = String(savedKeys.rootKey || '').trim();
}
}
return keys;
}
function requestCardHtml(request) {
const shortCode = String(request?.shortCode || '').trim() || '0000000';
const client = String(request?.requesterClientPlatform || 'unknown');
const expiresText = request?.expiresAtMs ? formatRelativeTime(request.expiresAtMs) : '—';
return `
<div class="card stack" data-pairing-id="${String(request?.pairingId || '')}">
<div class="row" style="align-items:flex-start;">
<div class="stack" style="gap:4px;">
<div style="font-size:28px; font-weight:700; letter-spacing:0.14em;">${shortCode}</div>
<span class="meta-muted">Платформа: ${client}</span>
<span class="meta-muted">Тип payload: ${Number(request?.payloadType || 0)}</span>
<span class="meta-muted">Истекает: ${expiresText}</span>
</div>
</div>
<div class="row" style="flex-wrap:wrap;">
<button class="ghost-btn" type="button" data-action="approve-device">Подключить без доп. ключей</button>
<button class="primary-btn" type="button" data-action="approve-full">Подключить и передать ключи</button>
<button class="text-btn" type="button" data-action="reject">Отклонить</button>
</div>
</div>
`;
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
let savedKeys = null;
let requests = [];
let cleanupEvent = () => {};
let disposed = false;
screen.append(
renderHeader({
title: 'Подключить по коду',
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
}),
);
const settingsCard = document.createElement('div');
settingsCard.className = 'card stack';
settingsCard.innerHTML = `
<p class="field-label">Пароль подключения</p>
<label class="stack">
<span class="meta-muted">Задайте пароль, который новое устройство введёт перед получением кода.</span>
<input class="input" id="pairing-password" type="password" autocomplete="new-password" placeholder="Новый pairing-пароль" />
</label>
<div class="row">
<button class="primary-btn" type="button" id="enable-pairing-btn">Включить / обновить</button>
<button class="ghost-btn" type="button" id="disable-pairing-btn">Выключить</button>
</div>
<p class="meta-muted">Пароль хранится на сервере только в виде hash. После включения можно переходить к заявкам ниже.</p>
`;
const keySummaryCard = document.createElement('div');
keySummaryCard.className = 'card stack';
keySummaryCard.innerHTML = `
<p class="field-label">Что передаётся при расширенном подключении</p>
<p class="meta-muted" id="pairing-key-summary">Проверяем локальные ключи...</p>
`;
const requestsCard = document.createElement('div');
requestsCard.className = 'card stack';
requestsCard.innerHTML = `
<div class="row" style="align-items:flex-end; gap:10px; flex-wrap:wrap;">
<label class="stack" style="flex:1 1 180px;">
<span class="field-label">Код нового устройства</span>
<input class="input" id="pairing-code-filter" inputmode="numeric" maxlength="7" placeholder="7 цифр" />
</label>
<button class="ghost-btn" type="button" id="refresh-pairing-requests">Обновить заявки</button>
</div>
<p class="meta-muted">Если код не введён, показываются все активные заявки. Для подключения без доп. ключей передаётся только device key.</p>
<div class="stack" id="pairing-requests-list"></div>
`;
const status = document.createElement('p');
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const passwordInput = settingsCard.querySelector('#pairing-password');
const enableBtn = settingsCard.querySelector('#enable-pairing-btn');
const disableBtn = settingsCard.querySelector('#disable-pairing-btn');
const keySummaryEl = keySummaryCard.querySelector('#pairing-key-summary');
const codeFilterInput = requestsCard.querySelector('#pairing-code-filter');
const refreshBtn = requestsCard.querySelector('#refresh-pairing-requests');
const requestsListEl = requestsCard.querySelector('#pairing-requests-list');
const renderRequests = () => {
const filterCode = normalizeCode(codeFilterInput.value);
const filtered = filterCode
? requests.filter((item) => normalizeCode(item?.shortCode) === filterCode)
: requests;
requestsListEl.innerHTML = '';
if (!filtered.length) {
const empty = document.createElement('p');
empty.className = 'meta-muted';
empty.textContent = filterCode
? 'Заявка с таким кодом пока не найдена.'
: 'Активных заявок сейчас нет.';
requestsListEl.append(empty);
return;
}
filtered.forEach((request) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = requestCardHtml(request);
requestsListEl.append(wrapper.firstElementChild);
});
};
const loadSavedKeys = async () => {
savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
const available = [];
if (savedKeys?.deviceKey) available.push('device');
if (savedKeys?.blockchainKey && state.deviceConnect.blockchain) available.push('blockchain');
if (savedKeys?.rootKey && state.deviceConnect.root) available.push('root');
keySummaryEl.textContent = available.length
? `При расширенном подключении будут переданы: ${available.join(', ')}.`
: 'На этом устройстве доступен только device key.';
};
const reloadRequests = async ({ silent = false } = {}) => {
try {
requests = await authService.listEspPairingRequests();
renderRequests();
if (!silent) {
setStatus(status, 'Список pairing-заявок обновлён.', 'info');
}
} catch (error) {
const message = toUserMessage(error, 'Не удалось загрузить pairing-заявки.');
setAuthError(message);
setStatus(status, message, 'error');
}
};
const setButtonsBusy = (flag) => {
enableBtn.disabled = flag;
disableBtn.disabled = flag;
refreshBtn.disabled = flag;
};
const approveRequest = async (request, mode) => {
const withExtras = mode === 'with-extras';
const keys = buildTransferKeys(savedKeys, { withExtras });
const payload = buildSecretsPayload({
login: state.session.login,
keys,
mode: withExtras ? 'with-extras' : 'device-only',
});
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
await authService.approveEspPairing(request?.pairingId, encryptedPayload);
showToast(withExtras ? 'Ключи переданы на новое устройство' : 'Новое устройство подключено');
setAuthInfo(withExtras ? 'Заявка подтверждена, ключи переданы.' : 'Заявка подтверждена без передачи доп. ключей.');
await refreshSessions().catch(() => {});
await reloadRequests({ silent: true });
};
settingsCard.addEventListener('click', async (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (target.id === 'enable-pairing-btn') {
const password = String(passwordInput.value || '');
if (!password) {
setStatus(status, 'Введите pairing-пароль.', 'error');
return;
}
setButtonsBusy(true);
try {
const passwordHash = await deriveEspPairingPasswordHash(state.session.login, password);
const payload = await authService.upsertEspPairingSettings({
enabled: true,
passwordHash,
ttlSeconds: 180,
});
setAuthInfo(`Подключение по коду включено. TTL: ${payload?.ttlSeconds || 180} сек.`);
setStatus(status, 'Подключение по коду включено или обновлено.', 'info');
passwordInput.value = '';
} catch (error) {
const message = toUserMessage(error, 'Не удалось включить pairing.');
setAuthError(message);
setStatus(status, message, 'error');
} finally {
setButtonsBusy(false);
}
return;
}
if (target.id === 'disable-pairing-btn') {
setButtonsBusy(true);
try {
await authService.upsertEspPairingSettings({
enabled: false,
passwordHash: '',
ttlSeconds: 180,
});
setAuthInfo('Подключение по коду выключено.');
setStatus(status, 'Подключение по коду выключено.', 'info');
} catch (error) {
const message = toUserMessage(error, 'Не удалось выключить pairing.');
setAuthError(message);
setStatus(status, message, 'error');
} finally {
setButtonsBusy(false);
}
}
});
refreshBtn.addEventListener('click', () => {
void reloadRequests();
});
codeFilterInput.addEventListener('input', () => {
codeFilterInput.value = normalizeCode(codeFilterInput.value);
renderRequests();
});
requestsListEl.addEventListener('click', async (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const action = String(target.dataset.action || '');
if (!action) return;
const card = target.closest('[data-pairing-id]');
if (!(card instanceof HTMLElement)) return;
const pairingId = String(card.dataset.pairingId || '');
const request = requests.find((item) => String(item?.pairingId || '') === pairingId);
if (!request) return;
const buttons = [...card.querySelectorAll('button')];
buttons.forEach((btn) => { btn.disabled = true; });
try {
if (action === 'approve-device') {
await approveRequest(request, 'device-only');
} else if (action === 'approve-full') {
await approveRequest(request, 'with-extras');
} else if (action === 'reject') {
await authService.rejectEspPairing(pairingId, 'rejected_by_user');
showToast('Заявка отклонена', { kind: 'error' });
await reloadRequests({ silent: true });
}
} catch (error) {
const message = toUserMessage(error, 'Не удалось обработать pairing-заявку.');
setAuthError(message);
setStatus(status, message, 'error');
buttons.forEach((btn) => { btn.disabled = false; });
}
});
void (async () => {
try {
await loadSavedKeys();
await reloadRequests({ silent: true });
cleanupEvent = authService.onEvent('IncomingEspPairingRequest', () => {
if (disposed) return;
showToast('Пришла новая заявка на подключение устройства');
void reloadRequests({ silent: true });
});
} catch (error) {
const message = toUserMessage(error, 'Не удалось подготовить экран pairing.');
setAuthError(message);
setStatus(status, message, 'error');
}
})();
screen.cleanup = () => {
disposed = true;
cleanupEvent();
};
screen.append(settingsCard, keySummaryCard, requestsCard, status);
return screen;
}

View File

@ -0,0 +1,240 @@
import { renderHeader } from '../components/header.js';
import {
authService,
authorizeSession,
clearAuthMessages,
clearBrowserClientData,
refreshSessions,
setAuthBusy,
setAuthError,
setAuthInfo,
state,
terminateCurrentSession,
} from '../state.js';
import { showToast } from '../services/channels-ux.js';
import { decryptPairingPayloadFromEnvelope, deriveEspPairingPasswordHash, createRequesterPairingMaterial } from '../services/device-pairing-service.js';
import { clearClientAuthData, saveEncryptedUserSecrets } from '../services/key-vault.js';
import { clearStoredMessages } from '../services/message-store.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'login-other-device-view', title: 'Войти через другое устройство', showAppChrome: false };
function setStatus(statusEl, message, kind = 'info') {
statusEl.classList.toggle('is-unavailable', kind === 'error');
statusEl.classList.toggle('is-available', kind !== 'error');
statusEl.textContent = message;
statusEl.style.display = message ? '' : 'none';
}
function codeCardHtml() {
return `
<div class="card stack">
<p class="field-label">Код подключения</p>
<div id="pairing-short-code" style="font-size:34px; font-weight:700; letter-spacing:0.18em;">0000000</div>
<p class="meta-muted" id="pairing-status-hint">Покажите этот код на уже подключённом устройстве в разделе «Подключить по коду».</p>
<p class="meta-muted" id="pairing-online-hint"></p>
<p class="meta-muted" id="pairing-expire-hint"></p>
</div>
`;
}
function formatExpiresAt(ms) {
const ts = Number(ms || 0);
if (!Number.isFinite(ts) || ts <= 0) return '';
return new Date(ts).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
let pollTimer = 0;
let activePairingId = '';
let requesterMaterial = null;
let isDisposed = false;
clearAuthMessages();
screen.append(
renderHeader({
title: 'Войти через другое устройство',
leftAction: { label: '←', onClick: () => navigate('login-view') },
}),
);
const formCard = document.createElement('div');
formCard.className = 'card stack';
formCard.innerHTML = `
<label class="stack">
<span class="field-label">Логин</span>
<input class="input" id="pair-login" type="text" autocomplete="username" placeholder="@login" value="${String(state.loginDraft.login || '')}" />
</label>
<label class="stack">
<span class="field-label">Пароль подключения</span>
<input class="input" id="pair-password" type="password" autocomplete="current-password" placeholder="Пароль, заданный на другом устройстве" />
</label>
<button class="primary-btn" type="button" id="pair-start-btn">Получить код</button>
<p class="meta-muted">Сначала вводится ваш логин и pairing-пароль. После этого появится 7-значный код для подтверждения на уже подключённом устройстве.</p>
`;
const status = document.createElement('p');
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const resultWrap = document.createElement('div');
resultWrap.className = 'stack';
resultWrap.style.display = 'none';
resultWrap.innerHTML = codeCardHtml();
const loginInput = formCard.querySelector('#pair-login');
const passwordInput = formCard.querySelector('#pair-password');
const startBtn = formCard.querySelector('#pair-start-btn');
const shortCodeEl = resultWrap.querySelector('#pairing-short-code');
const statusHintEl = resultWrap.querySelector('#pairing-status-hint');
const onlineHintEl = resultWrap.querySelector('#pairing-online-hint');
const expireHintEl = resultWrap.querySelector('#pairing-expire-hint');
const stopPolling = () => {
if (pollTimer) {
window.clearTimeout(pollTimer);
pollTimer = 0;
}
};
const finalizeAuthorizedLogin = async (keys, login) => {
const session = await authService.createSessionFromImportedSecrets(login, keys);
await clearStoredMessages().catch(() => {});
clearBrowserClientData();
await clearClientAuthData().catch(() => {});
await terminateCurrentSession();
await saveEncryptedUserSecrets(session.login, session.storagePwd, keys);
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
const resumed = await authService.resumeSession(session.login, session.sessionId);
authorizeSession({
login: resumed.login || session.login,
sessionId: resumed.sessionId || session.sessionId,
storagePwd: resumed.storagePwd || session.storagePwd,
});
state.loginDraft.login = resumed.login || session.login;
state.loginDraft.password = '';
await refreshSessions();
setAuthInfo(`Вход через другое устройство выполнен для @${resumed.login || session.login}.`);
showToast(`Устройство подключено для @${resumed.login || session.login}`);
navigate('profile-view');
};
const schedulePoll = () => {
stopPolling();
if (!activePairingId || isDisposed) return;
pollTimer = window.setTimeout(async () => {
try {
const payload = await authService.getEspPairingStatus(activePairingId);
const stateValue = String(payload?.state || '');
if (stateValue === 'created') {
schedulePoll();
return;
}
if (stateValue === 'approved') {
setStatus(status, 'Заявка подтверждена. Подключаем устройство...', 'info');
const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, requesterMaterial);
if (String(decoded?.type || '') !== 'shine-esp-pairing-transfer') {
throw new Error('Получен неподдерживаемый pairing payload');
}
await finalizeAuthorizedLogin(decoded.keys, decoded.login || loginInput.value);
return;
}
if (stateValue === 'rejected') {
stopPolling();
startBtn.disabled = false;
setStatus(status, 'Подключение отклонено на доверенном устройстве.', 'error');
statusHintEl.textContent = 'Заявка отклонена. Можно попробовать снова.';
return;
}
if (stateValue === 'expired') {
stopPolling();
startBtn.disabled = false;
setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error');
statusHintEl.textContent = 'Заявка истекла. Создайте новую заявку.';
return;
}
schedulePoll();
} catch (error) {
stopPolling();
startBtn.disabled = false;
const message = toUserMessage(error, 'Не удалось проверить статус pairing.');
setAuthError(message);
setStatus(status, message, 'error');
}
}, 2200);
};
startBtn.addEventListener('click', async () => {
const login = String(loginInput.value || '').trim();
const password = String(passwordInput.value || '');
if (!login) {
setStatus(status, 'Введите логин.', 'error');
return;
}
if (!password) {
setStatus(status, 'Введите пароль подключения.', 'error');
return;
}
startBtn.disabled = true;
setAuthBusy(true);
setAuthError('');
setAuthInfo('');
setStatus(status, 'Проверяем пользователя и создаём pairing-заявку...', 'info');
resultWrap.style.display = 'none';
stopPolling();
try {
await authService.reconnect(state.entrySettings.shineServer);
const user = await authService.getUser(login);
if (!user?.exists) {
throw new Error('Пользователь не найден.');
}
requesterMaterial = await createRequesterPairingMaterial();
const passwordHash = await deriveEspPairingPasswordHash(login, password);
const payload = await authService.startEspPairing({
login,
passwordHash,
requesterSessionKey: requesterMaterial.sessionKey,
payloadType: 3,
});
activePairingId = String(payload?.pairingId || '');
if (!activePairingId) {
throw new Error('Сервер не вернул pairingId.');
}
shortCodeEl.textContent = String(payload?.shortCode || '0000000');
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить устройство -> Подключить по коду.';
onlineHintEl.textContent = payload?.trustedSessionOnline
? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.'
: 'Сейчас нет онлайн доверенной сессии. Заявка будет ждать, пока пользователь откроет уже подключённое устройство.';
expireHintEl.textContent = payload?.expiresAtMs
? `Код действует до ${formatExpiresAt(payload.expiresAtMs)}.`
: '';
resultWrap.style.display = '';
state.loginDraft.login = login;
setStatus(status, 'Код создан. Ожидаем подтверждение на другом устройстве...', 'info');
schedulePoll();
} catch (error) {
startBtn.disabled = false;
const message = toUserMessage(error, 'Не удалось начать вход через другое устройство.');
setAuthError(message);
setStatus(status, message, 'error');
} finally {
setAuthBusy(false);
}
});
screen.cleanup = () => {
isDisposed = true;
stopPolling();
};
screen.append(formCard, status, resultWrap);
return screen;
}

View File

@ -48,9 +48,15 @@ export function render({ navigate }) {
loginButton.textContent = 'Войти по логину';
loginButton.addEventListener('click', () => navigate('login-password-view'));
const otherDeviceButton = document.createElement('button');
otherDeviceButton.className = 'text-btn';
otherDeviceButton.type = 'button';
otherDeviceButton.textContent = 'Войти через другое устройство';
otherDeviceButton.addEventListener('click', () => navigate('login-other-device-view'));
const actions = document.createElement('div');
actions.className = 'auth-actions login-actions-wide';
actions.append(cameraButton, loginButton);
actions.append(cameraButton, loginButton, otherDeviceButton);
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';

View File

@ -13,6 +13,7 @@ export const PRE_AUTH_PAGES = [
'devnet-topup-view',
'login-view',
'login-camera-view',
'login-other-device-view',
'login-password-view',
'key-storage-view',
];
@ -178,6 +179,7 @@ export function resolveToolbarActive(pageId) {
pageId === 'tools-settings-view' ||
pageId === 'device-view' ||
pageId === 'connect-device-view' ||
pageId === 'device-pairing-view' ||
pageId === 'device-qr-view' ||
pageId === 'device-camera-view' ||
pageId === 'show-keys-view' ||

View File

@ -966,6 +966,68 @@ export class AuthService {
if (response.status !== 200) throw opError('CloseActiveSession', response);
}
async upsertEspPairingSettings({ enabled, passwordHash = '', ttlSeconds = 180 }) {
const response = await this.ws.request('UpsertEspPairingSettings', {
enabled: !!enabled,
passwordHash: String(passwordHash || '').trim(),
ttlSeconds: Number(ttlSeconds) || 180,
});
if (response.status !== 200) throw opError('UpsertEspPairingSettings', response);
return response.payload || {};
}
async startEspPairing({
login,
passwordHash,
requesterSessionKey,
requesterSessionType = SESSION_TYPE_CLIENT,
requesterClientPlatform = makeClientPlatform(),
payloadType = 3,
}) {
const response = await this.ws.request('StartEspPairing', {
login: String(login || '').trim(),
passwordHash: String(passwordHash || '').trim(),
requesterSessionKey: String(requesterSessionKey || '').trim(),
requesterSessionType: Number(requesterSessionType) || SESSION_TYPE_CLIENT,
requesterClientPlatform: String(requesterClientPlatform || '').trim() || makeClientPlatform(),
payloadType: Number(payloadType) || 3,
});
if (response.status !== 200) throw opError('StartEspPairing', response);
return response.payload || {};
}
async listEspPairingRequests() {
const response = await this.ws.request('ListEspPairingRequests', {});
if (response.status !== 200) throw opError('ListEspPairingRequests', response);
return Array.isArray(response?.payload?.requests) ? response.payload.requests : [];
}
async approveEspPairing(pairingId, encryptedPayload) {
const response = await this.ws.request('ApproveEspPairing', {
pairingId: String(pairingId || '').trim(),
encryptedPayload: String(encryptedPayload || '').trim(),
});
if (response.status !== 200) throw opError('ApproveEspPairing', response);
return response.payload || {};
}
async rejectEspPairing(pairingId, reason = '') {
const response = await this.ws.request('RejectEspPairing', {
pairingId: String(pairingId || '').trim(),
reason: String(reason || '').trim(),
});
if (response.status !== 200) throw opError('RejectEspPairing', response);
return response.payload || {};
}
async getEspPairingStatus(pairingId) {
const response = await this.ws.request('GetEspPairingStatus', {
pairingId: String(pairingId || '').trim(),
});
if (response.status !== 200) throw opError('GetEspPairingStatus', response);
return response.payload || {};
}
async listSubscriptionsFeed(login, limit = 200) {
const response = await this.ws.request('ListSubscriptionsFeed', { login, limit });
if (response.status !== 200) throw opError('ListSubscriptionsFeed', response);

View File

@ -171,6 +171,21 @@ export async function deriveMasterSecretFromPassword(password, options = {}) {
});
}
export async function deriveOpaqueArgon2Hash(password, options = {}) {
const normalizedPassword = String(password ?? '');
const normalizedLogin = String(options?.login ?? '');
const normalizedSuffix = String(options?.suffix || 'opaque.hash');
const salt = await makeArgon2Salt(normalizedLogin, normalizedSuffix);
const passBytes = utf8Bytes(`${normalizeLoginForKdf(normalizedLogin)}\n${normalizedPassword}`);
const out = await argon2idAsync(passBytes, salt, {
t: 2,
m: 65536,
p: 1,
dkLen: 32,
});
return `argon2id$v=19$m=65536,t=2,p=1$${bytesToBase64(salt)}$${bytesToBase64(new Uint8Array(out))}`;
}
export async function deriveEd25519FromMasterSecret(masterSecret32, suffix) {
const secretBytes = masterSecret32 instanceof Uint8Array
? masterSecret32

View File

@ -0,0 +1,160 @@
import {
base64ToBytes,
bytesToBase64,
deriveOpaqueArgon2Hash,
exportEd25519PublicKeyB64,
exportPkcs8B64,
generateEd25519Pair,
sha256Bytes,
utf8Bytes,
} from './crypto-utils.js';
import {
edwardsToMontgomeryPriv,
edwardsToMontgomeryPub,
x25519,
} from 'https://esm.sh/@noble/curves@1.5.0/ed25519';
const PAIRING_HASH_SUFFIX = 'esp.pairing.password';
const PAIRING_ENVELOPE_PREFIX = 'shine-esp-pairing-v1:';
const ED25519_PKCS8_PREFIX = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]);
function getCryptoApi() {
const api = globalThis.crypto;
if (!api?.subtle || typeof api.getRandomValues !== 'function') {
throw new Error('Криптография браузера недоступна. Откройте приложение через HTTPS или localhost.');
}
return api;
}
function bytesToBase64Url(bytes) {
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function base64UrlToBytes(value) {
const normalized = String(value || '').trim().replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
return base64ToBytes(padded);
}
function extractSeedFromPkcs8(pkcs8B64) {
const raw = base64ToBytes(pkcs8B64);
if (raw.length !== ED25519_PKCS8_PREFIX.length + 32) {
throw new Error('Некорректный приватный Ed25519 ключ');
}
for (let i = 0; i < ED25519_PKCS8_PREFIX.length; i += 1) {
if (raw[i] !== ED25519_PKCS8_PREFIX[i]) {
throw new Error('Неподдерживаемый формат приватного Ed25519 ключа');
}
}
return raw.slice(ED25519_PKCS8_PREFIX.length);
}
function extractSessionPublicKeyB64(sessionKey) {
const raw = String(sessionKey || '').trim();
if (!raw.startsWith('ed25519/')) {
throw new Error('Неподдерживаемый requesterSessionKey');
}
const publicKeyB64 = raw.slice('ed25519/'.length).trim();
if (!publicKeyB64) {
throw new Error('Пустой requesterSessionKey');
}
return publicKeyB64;
}
async function importAesKeyFromSharedSecret(sharedSecretBytes) {
const digest = await sha256Bytes(sharedSecretBytes);
return getCryptoApi().subtle.importKey('raw', digest, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}
function normalizeKeys(keys = {}) {
return {
deviceKey: String(keys?.deviceKey || '').trim(),
blockchainKey: String(keys?.blockchainKey || '').trim(),
rootKey: String(keys?.rootKey || '').trim(),
};
}
export function detectPairingPayloadType(keys = {}) {
const normalized = normalizeKeys(keys);
if (normalized.rootKey) return 3;
if (normalized.blockchainKey) return 2;
return 1;
}
export async function deriveEspPairingPasswordHash(login, password) {
return deriveOpaqueArgon2Hash(password, {
login,
suffix: PAIRING_HASH_SUFFIX,
});
}
export async function createRequesterPairingMaterial() {
const sessionPair = await generateEd25519Pair();
const sessionPublicB64 = await exportEd25519PublicKeyB64(sessionPair.publicKey);
return {
sessionKey: `ed25519/${sessionPublicB64}`,
sessionPrivPkcs8: await exportPkcs8B64(sessionPair.privateKey),
};
}
export async function encryptPairingPayloadForRequester(requesterSessionKey, payload) {
const requesterPublicB64 = extractSessionPublicKeyB64(requesterSessionKey);
const requesterMontPub = edwardsToMontgomeryPub(base64ToBytes(requesterPublicB64));
const ephemeralPriv = x25519.utils.randomPrivateKey();
const ephemeralPub = x25519.getPublicKey(ephemeralPriv);
const sharedSecret = x25519.getSharedSecret(ephemeralPriv, requesterMontPub);
const aesKey = await importAesKeyFromSharedSecret(sharedSecret);
const iv = getCryptoApi().getRandomValues(new Uint8Array(12));
const plainBytes = utf8Bytes(JSON.stringify(payload));
const cipher = await getCryptoApi().subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, plainBytes);
const envelope = {
v: 1,
alg: 'x25519-aes256-gcm',
ephPubB64: bytesToBase64(ephemeralPub),
ivB64: bytesToBase64(iv),
cipherB64: bytesToBase64(new Uint8Array(cipher)),
createdAtMs: Date.now(),
};
return `${PAIRING_ENVELOPE_PREFIX}${bytesToBase64Url(utf8Bytes(JSON.stringify(envelope)))}`;
}
export async function decryptPairingPayloadFromEnvelope(encryptedPayload, requesterPairingMaterial) {
const raw = String(encryptedPayload || '').trim();
if (!raw.startsWith(PAIRING_ENVELOPE_PREFIX)) {
throw new Error('Неподдерживаемый формат pairing payload');
}
const jsonBytes = base64UrlToBytes(raw.slice(PAIRING_ENVELOPE_PREFIX.length));
const envelope = JSON.parse(new TextDecoder().decode(jsonBytes));
if (Number(envelope?.v) !== 1 || String(envelope?.alg || '') !== 'x25519-aes256-gcm') {
throw new Error('Неподдерживаемая версия pairing payload');
}
const requesterSeed = extractSeedFromPkcs8(String(requesterPairingMaterial?.sessionPrivPkcs8 || ''));
const requesterMontPriv = edwardsToMontgomeryPriv(requesterSeed);
const sharedSecret = x25519.getSharedSecret(requesterMontPriv, base64ToBytes(String(envelope?.ephPubB64 || '')));
const aesKey = await importAesKeyFromSharedSecret(sharedSecret);
const plain = await getCryptoApi().subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBytes(String(envelope?.ivB64 || '')) },
aesKey,
base64ToBytes(String(envelope?.cipherB64 || '')),
);
const payload = JSON.parse(new TextDecoder().decode(plain));
return {
...payload,
keys: normalizeKeys(payload?.keys),
};
}
export function buildSecretsPayload({ login, keys, mode }) {
return {
v: 1,
type: 'shine-esp-pairing-transfer',
login: String(login || '').trim(),
mode: String(mode || 'device-only').trim() || 'device-only',
keys: normalizeKeys(keys),
payloadType: detectPairingPayloadType(keys),
createdAtMs: Date.now(),
};
}