diff --git a/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md b/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md new file mode 100644 index 0000000..0c5b2fe --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md @@ -0,0 +1,25 @@ +# ui подключение по коду + +- краткое описание фичи: + - в UI добавлен новый сценарий подключения устройства через доверенную уже авторизованную сессию пользователя; + - на экране входа появилась кнопка `Войти через другое устройство`; + - на доверённом устройстве в `Подключить устройство` появилась кнопка `Подключить по коду`; + - доверённое устройство может включить pairing-пароль, увидеть заявки, подтвердить подключение только с `device key` или с передачей выбранных ключей. + +- что именно проверять: + - на уже авторизованном устройстве включить pairing-пароль; + - на новом устройстве открыть `Войти через другое устройство`, ввести `login + pairing password` и получить 7-значный код; + - на доверённом устройстве открыть `Подключить по коду`, найти заявку по коду и подтвердить её: + - без доп. ключей; + - с передачей выбранных ключей; + - убедиться, что новое устройство реально входит в аккаунт и сохраняет нужные ключи; + - отдельно проверить отклонение заявки и истечение TTL. + +- ожидаемый результат: + - новое устройство получает код, доверённое устройство видит ту же заявку и может её подтвердить или отклонить; + - после approve новое устройство автоматически входит в аккаунт; + - в режиме без доп. ключей переносится только `device key`; + - в расширенном режиме переносятся `device key` и отмеченные ключи `blockchain/root`, если они есть на доверённом устройстве. + +- статус: + - `pending` diff --git a/SHiNE-agent-bot-coder/AGENT.md b/SHiNE-agent-bot-coder/AGENT.md index a0236c1..f9c2f20 100644 --- a/SHiNE-agent-bot-coder/AGENT.md +++ b/SHiNE-agent-bot-coder/AGENT.md @@ -27,6 +27,7 @@ - Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния. - Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history//`. - Архив истории после `/new`: `data/history//archive/`. +- После `/new` для этого же пользователя должен сбрасываться и контекст продолжения Codex-сессии; следующий запрос запускается как новая сессия, не через resume. - Для просмотра истории игрока открывать файлы в его папке истории по username. - Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно. - Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты. diff --git a/SHiNE-agent-bot-coder/README.md b/SHiNE-agent-bot-coder/README.md index a6ab372..e1b815d 100644 --- a/SHiNE-agent-bot-coder/README.md +++ b/SHiNE-agent-bot-coder/README.md @@ -89,7 +89,7 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь - `/queue` — список задач в очереди. - `/stop` — остановить текущую задачу. - `/cancel ` — удалить задачу по id/префиксу или очистить очередь. -- `/new` — архивировать текущую историю и начать новый диалог. +- `/new` — архивировать текущую историю, сбросить продолжение Codex-сессии для этого пользователя и начать новый диалог. - `/voice_on` — включить озвучивание финальных ответов для текущего пользователя. - `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя. - `/voice_rewrite_on` — включить адаптацию текста перед озвучкой. diff --git a/VERSION.properties b/VERSION.properties index 29012d9..fdc2f20 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.192 -server.version=1.2.181 +client.version=1.2.193 +server.version=1.2.182 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 57972ce..a5b8ad7 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -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, diff --git a/shine-UI/js/pages/connect-device-view.js b/shine-UI/js/pages/connect-device-view.js index 80787b2..b135721 100644 --- a/shine-UI/js/pages/connect-device-view.js +++ b/shine-UI/js/pages/connect-device-view.js @@ -28,6 +28,7 @@ export function render({ navigate }) {
+
`; @@ -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 }) {

подключение происходит напрямую через QR

сервер не используется

текущая логика: устройство 1 показывает QR, устройство 2 сканирует

-

обратный сценарий пока не реализован

+

для сценария через сервер используйте кнопку «Подключить по коду»

@@ -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 = 'Не удалось прочитать сохранённые ключи на этом устройстве.'; } })(); diff --git a/shine-UI/js/pages/device-pairing-view.js b/shine-UI/js/pages/device-pairing-view.js new file mode 100644 index 0000000..705b50e --- /dev/null +++ b/shine-UI/js/pages/device-pairing-view.js @@ -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 ` +
+
+
+
${shortCode}
+ Платформа: ${client} + Тип payload: ${Number(request?.payloadType || 0)} + Истекает: ${expiresText} +
+
+
+ + + +
+
+ `; +} + +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 = ` +

Пароль подключения

+ +
+ + +
+

Пароль хранится на сервере только в виде hash. После включения можно переходить к заявкам ниже.

+ `; + + const keySummaryCard = document.createElement('div'); + keySummaryCard.className = 'card stack'; + keySummaryCard.innerHTML = ` +

Что передаётся при расширенном подключении

+

Проверяем локальные ключи...

+ `; + + const requestsCard = document.createElement('div'); + requestsCard.className = 'card stack'; + requestsCard.innerHTML = ` +
+ + +
+

Если код не введён, показываются все активные заявки. Для подключения без доп. ключей передаётся только device key.

+
+ `; + + 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; +} diff --git a/shine-UI/js/pages/login-other-device-view.js b/shine-UI/js/pages/login-other-device-view.js new file mode 100644 index 0000000..f59acf3 --- /dev/null +++ b/shine-UI/js/pages/login-other-device-view.js @@ -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 ` +
+

Код подключения

+
0000000
+

Покажите этот код на уже подключённом устройстве в разделе «Подключить по коду».

+

+

+
+ `; +} + +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 = ` + + + +

Сначала вводится ваш логин и pairing-пароль. После этого появится 7-значный код для подтверждения на уже подключённом устройстве.

+ `; + + 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; +} diff --git a/shine-UI/js/pages/login-view.js b/shine-UI/js/pages/login-view.js index c6590c0..7912de8 100644 --- a/shine-UI/js/pages/login-view.js +++ b/shine-UI/js/pages/login-view.js @@ -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'; diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index 9bc56d2..1bc2fc7 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -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' || diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index e370da6..c453d7c 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -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); diff --git a/shine-UI/js/services/crypto-utils.js b/shine-UI/js/services/crypto-utils.js index d15f291..93e063a 100644 --- a/shine-UI/js/services/crypto-utils.js +++ b/shine-UI/js/services/crypto-utils.js @@ -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 diff --git a/shine-UI/js/services/device-pairing-service.js b/shine-UI/js/services/device-pairing-service.js new file mode 100644 index 0000000..a8e037a --- /dev/null +++ b/shine-UI/js/services/device-pairing-service.js @@ -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(), + }; +}