Добавить UI pairing по коду и обновить документацию агента
This commit is contained in:
parent
b166013707
commit
c681b4d684
@ -0,0 +1,25 @@
|
|||||||
|
# ui подключение по коду
|
||||||
|
|
||||||
|
- краткое описание фичи:
|
||||||
|
- в UI добавлен новый сценарий подключения устройства через доверенную уже авторизованную сессию пользователя;
|
||||||
|
- на экране входа появилась кнопка `Войти через другое устройство`;
|
||||||
|
- на доверённом устройстве в `Подключить устройство` появилась кнопка `Подключить по коду`;
|
||||||
|
- доверённое устройство может включить pairing-пароль, увидеть заявки, подтвердить подключение только с `device key` или с передачей выбранных ключей.
|
||||||
|
|
||||||
|
- что именно проверять:
|
||||||
|
- на уже авторизованном устройстве включить pairing-пароль;
|
||||||
|
- на новом устройстве открыть `Войти через другое устройство`, ввести `login + pairing password` и получить 7-значный код;
|
||||||
|
- на доверённом устройстве открыть `Подключить по коду`, найти заявку по коду и подтвердить её:
|
||||||
|
- без доп. ключей;
|
||||||
|
- с передачей выбранных ключей;
|
||||||
|
- убедиться, что новое устройство реально входит в аккаунт и сохраняет нужные ключи;
|
||||||
|
- отдельно проверить отклонение заявки и истечение TTL.
|
||||||
|
|
||||||
|
- ожидаемый результат:
|
||||||
|
- новое устройство получает код, доверённое устройство видит ту же заявку и может её подтвердить или отклонить;
|
||||||
|
- после approve новое устройство автоматически входит в аккаунт;
|
||||||
|
- в режиме без доп. ключей переносится только `device key`;
|
||||||
|
- в расширенном режиме переносятся `device key` и отмеченные ключи `blockchain/root`, если они есть на доверённом устройстве.
|
||||||
|
|
||||||
|
- статус:
|
||||||
|
- `pending`
|
||||||
@ -27,6 +27,7 @@
|
|||||||
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
|
- Сервис ведёт состояние активной задачи и текущего файла истории, а после рестарта продолжает незавершённую обработку с учётом сохранённого состояния.
|
||||||
- Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history/<username>/`.
|
- Истории диалогов хранятся в JSONL по каждому разрешённому username отдельно: `data/history/<username>/`.
|
||||||
- Архив истории после `/new`: `data/history/<username>/archive/`.
|
- Архив истории после `/new`: `data/history/<username>/archive/`.
|
||||||
|
- После `/new` для этого же пользователя должен сбрасываться и контекст продолжения Codex-сессии; следующий запрос запускается как новая сессия, не через resume.
|
||||||
- Для просмотра истории игрока открывать файлы в его папке истории по username.
|
- Для просмотра истории игрока открывать файлы в его папке истории по username.
|
||||||
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
|
- Дедупликация входящих Telegram update нужна, чтобы одно сообщение не попало в обработку повторно.
|
||||||
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
|
- Если Codex молчит во время активной задачи 2 минуты подряд, сервис отправляет аварийный статус с общим временем работы задачи; при дальнейшем молчании повторяет статус каждые 2 минуты.
|
||||||
|
|||||||
@ -89,7 +89,7 @@ python3 SHiNE-agent-bot-coder/py_bot_service.py --selftest-codex "Ответь
|
|||||||
- `/queue` — список задач в очереди.
|
- `/queue` — список задач в очереди.
|
||||||
- `/stop` — остановить текущую задачу.
|
- `/stop` — остановить текущую задачу.
|
||||||
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
|
- `/cancel <id|all>` — удалить задачу по id/префиксу или очистить очередь.
|
||||||
- `/new` — архивировать текущую историю и начать новый диалог.
|
- `/new` — архивировать текущую историю, сбросить продолжение Codex-сессии для этого пользователя и начать новый диалог.
|
||||||
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
|
- `/voice_on` — включить озвучивание финальных ответов для текущего пользователя.
|
||||||
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
|
- `/voice_off` — выключить озвучивание финальных ответов для текущего пользователя.
|
||||||
- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой.
|
- `/voice_rewrite_on` — включить адаптацию текста перед озвучкой.
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.192
|
client.version=1.2.193
|
||||||
server.version=1.2.181
|
server.version=1.2.182
|
||||||
|
|||||||
@ -42,6 +42,7 @@ import * as topupView from './pages/topup-view.js';
|
|||||||
import * as devnetTopupView from './pages/devnet-topup-view.js';
|
import * as devnetTopupView from './pages/devnet-topup-view.js';
|
||||||
import * as loginView from './pages/login-view.js';
|
import * as loginView from './pages/login-view.js';
|
||||||
import * as loginCameraView from './pages/login-camera-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 loginPasswordView from './pages/login-password-view.js';
|
||||||
import * as keyStorageView from './pages/key-storage-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 toolsSettingsView from './pages/tools-settings-view.js';
|
||||||
import * as deviceView from './pages/device-view.js?v=202606131435';
|
import * as deviceView from './pages/device-view.js?v=202606131435';
|
||||||
import * as connectDeviceView from './pages/connect-device-view.js';
|
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 deviceQrView from './pages/device-qr-view.js';
|
||||||
import * as deviceCameraView from './pages/device-camera-view.js';
|
import * as deviceCameraView from './pages/device-camera-view.js';
|
||||||
import * as showKeysView from './pages/show-keys-view.js';
|
import * as showKeysView from './pages/show-keys-view.js';
|
||||||
@ -85,6 +87,7 @@ const routes = {
|
|||||||
'devnet-topup-view': devnetTopupView,
|
'devnet-topup-view': devnetTopupView,
|
||||||
'login-view': loginView,
|
'login-view': loginView,
|
||||||
'login-camera-view': loginCameraView,
|
'login-camera-view': loginCameraView,
|
||||||
|
'login-other-device-view': loginOtherDeviceView,
|
||||||
'login-password-view': loginPasswordView,
|
'login-password-view': loginPasswordView,
|
||||||
'key-storage-view': keyStorageView,
|
'key-storage-view': keyStorageView,
|
||||||
'profile-view': profileView,
|
'profile-view': profileView,
|
||||||
@ -96,6 +99,7 @@ const routes = {
|
|||||||
'tools-settings-view': toolsSettingsView,
|
'tools-settings-view': toolsSettingsView,
|
||||||
'device-view': deviceView,
|
'device-view': deviceView,
|
||||||
'connect-device-view': connectDeviceView,
|
'connect-device-view': connectDeviceView,
|
||||||
|
'device-pairing-view': devicePairingView,
|
||||||
'device-qr-view': deviceQrView,
|
'device-qr-view': deviceQrView,
|
||||||
'device-camera-view': deviceCameraView,
|
'device-camera-view': deviceCameraView,
|
||||||
'show-keys-view': showKeysView,
|
'show-keys-view': showKeysView,
|
||||||
|
|||||||
@ -28,6 +28,7 @@ export function render({ navigate }) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<button class="primary-btn" type="button" id="open-qr">Показать QR-код для подключения</button>
|
<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>
|
<button class="text-btn" type="button" id="open-camera">Подключить через камеру</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -37,6 +38,7 @@ export function render({ navigate }) {
|
|||||||
const deviceToggle = card.querySelector('#connect-device');
|
const deviceToggle = card.querySelector('#connect-device');
|
||||||
const statusEl = card.querySelector('#connect-keys-status');
|
const statusEl = card.querySelector('#connect-keys-status');
|
||||||
const openQrBtn = card.querySelector('#open-qr');
|
const openQrBtn = card.querySelector('#open-qr');
|
||||||
|
const openPairBtn = card.querySelector('#open-pairing');
|
||||||
deviceToggle.checked = true;
|
deviceToggle.checked = true;
|
||||||
|
|
||||||
rootToggle.addEventListener('change', () => {
|
rootToggle.addEventListener('change', () => {
|
||||||
@ -70,7 +72,7 @@ export function render({ navigate }) {
|
|||||||
<p class="meta-muted">подключение происходит напрямую через QR</p>
|
<p class="meta-muted">подключение происходит напрямую через QR</p>
|
||||||
<p class="meta-muted">сервер не используется</p>
|
<p class="meta-muted">сервер не используется</p>
|
||||||
<p class="meta-muted">текущая логика: устройство 1 показывает QR, устройство 2 сканирует</p>
|
<p class="meta-muted">текущая логика: устройство 1 показывает QR, устройство 2 сканирует</p>
|
||||||
<p class="meta-muted">обратный сценарий пока не реализован</p>
|
<p class="meta-muted">для сценария через сервер используйте кнопку «Подключить по коду»</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="primary-btn" type="button" data-close="true">OK</button>
|
<button class="primary-btn" type="button" data-close="true">OK</button>
|
||||||
</div>
|
</div>
|
||||||
@ -87,6 +89,7 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
card.querySelector('#tech-help').addEventListener('click', openHelp);
|
card.querySelector('#tech-help').addEventListener('click', openHelp);
|
||||||
card.querySelector('#open-qr').addEventListener('click', () => navigate('device-qr-view'));
|
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'));
|
card.querySelector('#open-camera').addEventListener('click', () => navigate('device-camera-view'));
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
@ -109,6 +112,7 @@ export function render({ navigate }) {
|
|||||||
blockchainToggle.checked = state.deviceConnect.blockchain;
|
blockchainToggle.checked = state.deviceConnect.blockchain;
|
||||||
deviceToggle.checked = hasDevice;
|
deviceToggle.checked = hasDevice;
|
||||||
openQrBtn.disabled = !hasDevice;
|
openQrBtn.disabled = !hasDevice;
|
||||||
|
openPairBtn.disabled = !hasDevice;
|
||||||
|
|
||||||
const available = [
|
const available = [
|
||||||
hasDevice ? 'device' : '',
|
hasDevice ? 'device' : '',
|
||||||
@ -126,6 +130,7 @@ export function render({ navigate }) {
|
|||||||
state.deviceConnect.blockchain = false;
|
state.deviceConnect.blockchain = false;
|
||||||
state.deviceConnect.device = false;
|
state.deviceConnect.device = false;
|
||||||
openQrBtn.disabled = true;
|
openQrBtn.disabled = true;
|
||||||
|
openPairBtn.disabled = true;
|
||||||
statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
|
statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
321
shine-UI/js/pages/device-pairing-view.js
Normal file
321
shine-UI/js/pages/device-pairing-view.js
Normal 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;
|
||||||
|
}
|
||||||
240
shine-UI/js/pages/login-other-device-view.js
Normal file
240
shine-UI/js/pages/login-other-device-view.js
Normal 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;
|
||||||
|
}
|
||||||
@ -48,9 +48,15 @@ export function render({ navigate }) {
|
|||||||
loginButton.textContent = 'Войти по логину';
|
loginButton.textContent = 'Войти по логину';
|
||||||
loginButton.addEventListener('click', () => navigate('login-password-view'));
|
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');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'auth-actions login-actions-wide';
|
actions.className = 'auth-actions login-actions-wide';
|
||||||
actions.append(cameraButton, loginButton);
|
actions.append(cameraButton, loginButton, otherDeviceButton);
|
||||||
|
|
||||||
const backButton = document.createElement('button');
|
const backButton = document.createElement('button');
|
||||||
backButton.className = 'ghost-btn';
|
backButton.className = 'ghost-btn';
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export const PRE_AUTH_PAGES = [
|
|||||||
'devnet-topup-view',
|
'devnet-topup-view',
|
||||||
'login-view',
|
'login-view',
|
||||||
'login-camera-view',
|
'login-camera-view',
|
||||||
|
'login-other-device-view',
|
||||||
'login-password-view',
|
'login-password-view',
|
||||||
'key-storage-view',
|
'key-storage-view',
|
||||||
];
|
];
|
||||||
@ -178,6 +179,7 @@ export function resolveToolbarActive(pageId) {
|
|||||||
pageId === 'tools-settings-view' ||
|
pageId === 'tools-settings-view' ||
|
||||||
pageId === 'device-view' ||
|
pageId === 'device-view' ||
|
||||||
pageId === 'connect-device-view' ||
|
pageId === 'connect-device-view' ||
|
||||||
|
pageId === 'device-pairing-view' ||
|
||||||
pageId === 'device-qr-view' ||
|
pageId === 'device-qr-view' ||
|
||||||
pageId === 'device-camera-view' ||
|
pageId === 'device-camera-view' ||
|
||||||
pageId === 'show-keys-view' ||
|
pageId === 'show-keys-view' ||
|
||||||
|
|||||||
@ -966,6 +966,68 @@ export class AuthService {
|
|||||||
if (response.status !== 200) throw opError('CloseActiveSession', response);
|
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) {
|
async listSubscriptionsFeed(login, limit = 200) {
|
||||||
const response = await this.ws.request('ListSubscriptionsFeed', { login, limit });
|
const response = await this.ws.request('ListSubscriptionsFeed', { login, limit });
|
||||||
if (response.status !== 200) throw opError('ListSubscriptionsFeed', response);
|
if (response.status !== 200) throw opError('ListSubscriptionsFeed', response);
|
||||||
|
|||||||
@ -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) {
|
export async function deriveEd25519FromMasterSecret(masterSecret32, suffix) {
|
||||||
const secretBytes = masterSecret32 instanceof Uint8Array
|
const secretBytes = masterSecret32 instanceof Uint8Array
|
||||||
? masterSecret32
|
? masterSecret32
|
||||||
|
|||||||
160
shine-UI/js/services/device-pairing-service.js
Normal file
160
shine-UI/js/services/device-pairing-service.js
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user