SHiNE-server/SHiNE-browser-plugin-wallet/background.js

522 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createRequesterPairingMaterial, decryptPairingPayloadFromEnvelope, deriveEspPairingPasswordHash } from './js/lib/device-pairing.js';
import { loadPluginSettings, loadSessionMaterial, savePluginSettings, saveSessionMaterial, clearSessionMaterial } from './js/lib/session-store.js';
import { ShineApiClient } from './js/lib/shine-api.js';
import {
DEFAULT_SHINE_SERVER_LOGIN,
buildHttpBase,
normalizeServerLogin,
readWalletProfileByLogin,
resolveShineServerByServerLogin,
} from './js/lib/shine-server-resolver.js';
const state = {
api: null,
settings: {
serverLogin: DEFAULT_SHINE_SERVER_LOGIN,
serverHttp: buildHttpBase('shineup.me'),
serverUrl: 'wss://shineup.me/ws',
login: '',
},
requesterMaterial: null,
pairingId: '',
expiresAtMs: 0,
shortCode: '',
trustedSessionOnline: false,
pollTimer: 0,
activeSession: null,
connectionOnline: false,
walletProfile: null,
signing: {
selectedKeyId: 'device',
selectedDeviceName: '',
devicesResolvedAtMs: 0,
},
statusText: '',
statusKind: 'info',
};
function setStatus(message = '', kind = 'info') {
state.statusText = String(message || '');
state.statusKind = kind === 'error' ? 'error' : 'info';
}
function stopPoll() {
if (state.pollTimer) {
clearTimeout(state.pollTimer);
state.pollTimer = 0;
}
}
function clearPairingState() {
stopPoll();
state.requesterMaterial = null;
state.pairingId = '';
state.expiresAtMs = 0;
state.shortCode = '';
state.trustedSessionOnline = false;
}
function ensureApi(serverUrl = state.settings.serverUrl) {
const normalized = String(serverUrl || '').trim() || 'wss://shineup.me/ws';
if (!state.api || state.api.serverUrl !== normalized) {
state.api?.close();
state.api = new ShineApiClient(normalized);
}
return state.api;
}
async function resolveSettingsServer(nextSettings = {}) {
const serverLogin = normalizeServerLogin(nextSettings?.serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN)
|| DEFAULT_SHINE_SERVER_LOGIN;
const resolved = await resolveShineServerByServerLogin(serverLogin);
return {
serverLogin: resolved.serverLogin,
serverHttp: resolved.serverHttp,
serverUrl: resolved.serverUrl,
};
}
async function loadStateFromStorage() {
const settings = await loadPluginSettings();
const storedServerLogin = normalizeServerLogin(settings?.serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN)
|| DEFAULT_SHINE_SERVER_LOGIN;
state.settings = {
serverLogin: storedServerLogin,
serverHttp: String(settings?.serverHttp || state.settings.serverHttp || buildHttpBase('shineup.me')).trim() || buildHttpBase('shineup.me'),
serverUrl: String(settings?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim() || 'wss://shineup.me/ws',
login: String(settings?.login || '').trim(),
};
state.activeSession = await loadSessionMaterial();
state.walletProfile = state.activeSession?.walletProfile || null;
state.signing = {
selectedKeyId: String(state.activeSession?.selectedKeyId || 'device'),
selectedDeviceName: String(state.activeSession?.selectedDeviceName || ''),
devicesResolvedAtMs: Number(state.activeSession?.devicesResolvedAtMs || 0),
};
}
async function persistSettings(nextSettings = {}) {
const resolved = await resolveSettingsServer(nextSettings);
state.settings = {
...state.settings,
...nextSettings,
...resolved,
};
await savePluginSettings(state.settings);
return state.settings;
}
async function saveActiveSessionRecord() {
if (!state.activeSession) return;
const nextRecord = {
...state.activeSession,
walletProfile: state.walletProfile,
selectedKeyId: state.signing.selectedKeyId,
selectedDeviceName: state.signing.selectedDeviceName,
devicesResolvedAtMs: state.signing.devicesResolvedAtMs,
};
state.activeSession = nextRecord;
await saveSessionMaterial(nextRecord);
}
function shortKey(value = '', size = 10) {
const raw = String(value || '').trim();
return raw ? raw.slice(0, size) : '';
}
function buildSigningKeyOptions(walletProfile) {
const deviceKey = String(walletProfile?.publicKeys?.deviceKeyBase58 || '').trim();
if (!deviceKey) return [];
return [{
id: 'device',
label: `deviceKey (ed25519, ${shortKey(deviceKey)})`,
keyType: 'ed25519',
publicKeyBase58: deviceKey,
}];
}
function mergeHomeserverStatuses(publishedHomeservers = [], serverSessions = []) {
const published = Array.isArray(publishedHomeservers) ? publishedHomeservers : [];
const homeserverSessions = Array.isArray(serverSessions)
? serverSessions.filter((item) => Number(item?.sessionType || 0) === 100)
: [];
const onlineHomeservers = homeserverSessions.filter((item) => !!item?.onlineOnThisServer);
return published.map((item) => {
let onlineState = 'unknown';
if (published.length === 1) {
onlineState = onlineHomeservers.length > 0 ? 'online' : 'offline';
} else if (onlineHomeservers.length === 0) {
onlineState = 'offline';
} else if (onlineHomeservers.length === published.length) {
onlineState = 'online';
}
return {
...item,
onlineState,
onlineLabel: onlineState === 'online' ? 'online' : onlineState === 'offline' ? 'offline' : 'unknown',
};
});
}
async function hydrateWalletProfile(login) {
const cleanLogin = String(login || state.activeSession?.login || state.settings.login || '').trim();
if (!cleanLogin) throw new Error('Нет логина для чтения PDA кошелька.');
const profile = await readWalletProfileByLogin(cleanLogin);
const signingKeyOptions = buildSigningKeyOptions(profile);
const selectedKeyId = signingKeyOptions.some((item) => item.id === state.signing.selectedKeyId)
? state.signing.selectedKeyId
: (signingKeyOptions[0]?.id || '');
const selectedDeviceName = state.signing.selectedDeviceName
|| String(profile?.homeserverSessions?.[0]?.sessionName || '');
state.walletProfile = {
...profile,
signingKeyOptions,
homeserverSessions: Array.isArray(profile.homeserverSessions) ? profile.homeserverSessions.map((item) => ({
...item,
onlineState: 'unknown',
onlineLabel: 'unknown',
})) : [],
};
state.signing = {
...state.signing,
selectedKeyId,
selectedDeviceName,
};
await saveActiveSessionRecord();
return state.walletProfile;
}
async function resumeActiveSession({ keepConnected = false } = {}) {
const sessionRecord = await loadSessionMaterial();
state.activeSession = sessionRecord;
if (!sessionRecord) {
state.connectionOnline = false;
setStatus('Wallet-session ещё не подключена.', 'info');
return { ok: true, connected: false };
}
try {
await persistSettings({
serverLogin: String(sessionRecord?.serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN).trim(),
serverHttp: String(sessionRecord?.serverHttp || state.settings.serverHttp || buildHttpBase('shineup.me')).trim(),
serverUrl: String(sessionRecord?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim(),
login: String(sessionRecord?.login || state.settings.login || '').trim(),
});
const resumed = await ensureApi().resumeSession(sessionRecord);
state.connectionOnline = !!keepConnected;
if (!keepConnected) {
ensureApi().close();
state.api = null;
setStatus(`Wallet-session сохранена для @${resumed.login}. Подключение будет открываться только по действию.`, 'info');
} else {
setStatus(`Wallet-session активна для @${resumed.login}.`, 'info');
}
return { ok: true, connected: true, login: resumed.login, sessionId: resumed.sessionId };
} catch (error) {
state.connectionOnline = false;
setStatus(error.message || 'Не удалось восстановить wallet-session.', 'error');
return { ok: false, connected: false, error: state.statusText };
}
}
async function attachApprovedSession(payload) {
if (String(payload?.type || '') !== 'shine-esp-session-attach') {
throw new Error('Доверенное устройство вернуло неподдерживаемый payload. Для plugin нужен session-only approve.');
}
const login = String(payload?.login || state.settings.login || '').trim();
const approvedSession = payload?.session || {};
const sessionRecord = {
login,
sessionId: String(approvedSession?.sessionId || '').trim(),
sessionKey: state.requesterMaterial?.sessionKey || '',
sessionPrivPkcs8: state.requesterMaterial?.sessionPrivPkcs8 || '',
sessionType: Number(approvedSession?.sessionType || 50) || 50,
serverLogin: state.settings.serverLogin,
serverHttp: state.settings.serverHttp,
serverUrl: state.settings.serverUrl,
};
if (!sessionRecord.login || !sessionRecord.sessionId || !sessionRecord.sessionKey || !sessionRecord.sessionPrivPkcs8) {
throw new Error('Получен неполный session-only payload');
}
await clearSessionMaterial();
state.activeSession = sessionRecord;
await hydrateWalletProfile(login);
await saveActiveSessionRecord();
await persistSettings({
login: sessionRecord.login,
serverUrl: sessionRecord.serverUrl,
});
state.connectionOnline = false;
}
async function pollPairingStatus() {
if (!state.pairingId || !state.requesterMaterial) return;
try {
const payload = await ensureApi().getEspPairingStatus(state.pairingId);
const stateValue = String(payload?.state || '');
if (stateValue === 'created') {
state.pollTimer = setTimeout(() => {
void pollPairingStatus();
}, 2200);
return;
}
if (stateValue === 'approved') {
const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, state.requesterMaterial);
await attachApprovedSession(decoded);
clearPairingState();
setStatus('Wallet-session создана и сохранена. Кошелёк остаётся офлайн до запроса подписи.', 'info');
return;
}
if (stateValue === 'rejected') {
clearPairingState();
setStatus('Заявка отклонена на доверенном устройстве.', 'error');
return;
}
if (stateValue === 'expired' || stateValue === 'canceled') {
clearPairingState();
setStatus('Ожидание подключения завершено.', 'error');
return;
}
state.pollTimer = setTimeout(() => {
void pollPairingStatus();
}, 2200);
} catch (error) {
clearPairingState();
setStatus(error.message || 'Не удалось проверить pairing-статус.', 'error');
}
}
async function startPairing({ login, usePassword, password, serverLogin }) {
const cleanLogin = String(login || '').trim();
if (!cleanLogin) {
throw new Error('Введите логин.');
}
await persistSettings({
serverLogin: String(serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN).trim(),
login: cleanLogin,
});
clearPairingState();
setStatus('Проверяем пользователя и создаём wallet-session заявку...', 'info');
const api = ensureApi();
const user = await api.getUser(cleanLogin);
if (user?.exists !== true) {
throw new Error('Пользователь не найден.');
}
state.requesterMaterial = await createRequesterPairingMaterial();
const passwordHash = usePassword
? await deriveEspPairingPasswordHash(cleanLogin, String(password || ''))
: '';
const payload = await api.startEspPairing({
login: cleanLogin,
passwordHash,
requesterSessionKey: state.requesterMaterial.sessionKey,
payloadType: 1,
});
state.pairingId = String(payload?.pairingId || '').trim();
state.expiresAtMs = Number(payload?.expiresAtMs || 0);
state.shortCode = String(payload?.shortCode || '0000000');
state.trustedSessionOnline = !!payload?.trustedSessionOnline;
if (!state.pairingId) {
throw new Error('Сервер не вернул pairingId.');
}
state.pollTimer = setTimeout(() => {
void pollPairingStatus();
}, 1800);
setStatus('Wallet-session заявка создана. Ожидаем подтверждение на доверенном устройстве.', 'info');
return {
pairingId: state.pairingId,
shortCode: String(payload?.shortCode || '0000000'),
expiresAtMs: state.expiresAtMs,
trustedSessionOnline: !!payload?.trustedSessionOnline,
};
}
async function cancelPairing() {
if (!state.pairingId || !state.requesterMaterial?.sessionKey) {
clearPairingState();
return { ok: true };
}
await ensureApi().cancelEspPairing(state.pairingId, state.requesterMaterial.sessionKey);
clearPairingState();
setStatus('Ожидание подключения отменено.', 'info');
return { ok: true };
}
async function disconnectSession() {
ensureApi().close();
state.api = null;
await clearSessionMaterial();
state.activeSession = null;
state.connectionOnline = false;
state.walletProfile = null;
state.signing = {
selectedKeyId: 'device',
selectedDeviceName: '',
devicesResolvedAtMs: 0,
};
setStatus('Сохранённая wallet-session удалена из plugin.', 'info');
return { ok: true };
}
async function refreshWalletDevices() {
if (!state.activeSession?.login) {
throw new Error('Сначала подключите wallet-session.');
}
await hydrateWalletProfile(state.activeSession.login);
const resumed = await resumeActiveSession({ keepConnected: true });
if (!resumed.ok) {
throw new Error(resumed.error || 'Не удалось открыть wallet-session.');
}
try {
const sessions = await ensureApi().listSessions();
state.walletProfile = {
...state.walletProfile,
homeserverSessions: mergeHomeserverStatuses(state.walletProfile?.homeserverSessions, sessions),
};
state.signing.devicesResolvedAtMs = Date.now();
if (!state.signing.selectedDeviceName && state.walletProfile.homeserverSessions[0]?.sessionName) {
state.signing.selectedDeviceName = state.walletProfile.homeserverSessions[0].sessionName;
}
await saveActiveSessionRecord();
setStatus('Список доверенных homeserver-устройств обновлён.', 'info');
return {
ok: true,
devices: state.walletProfile.homeserverSessions,
};
} finally {
ensureApi().close();
state.api = null;
state.connectionOnline = false;
}
}
async function updateSigningSelection({ selectedKeyId, selectedDeviceName } = {}) {
state.signing = {
...state.signing,
selectedKeyId: String(selectedKeyId || state.signing.selectedKeyId || ''),
selectedDeviceName: String(selectedDeviceName || state.signing.selectedDeviceName || ''),
};
await saveActiveSessionRecord();
return { ok: true };
}
async function prepareSignSignal() {
if (!state.activeSession?.login) {
throw new Error('Сначала подключите wallet-session.');
}
if (!state.signing.selectedKeyId) {
throw new Error('Не выбран ключ подписи.');
}
if (!state.signing.selectedDeviceName) {
throw new Error('Не выбрано устройство homeserver.');
}
const selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName);
if (!selectedDevice) {
throw new Error('Выбранное устройство не найдено в PDA аккаунта.');
}
setStatus(
`Каркас готов: запрос подписи должен идти через ${selectedDevice.sessionName}. Сам signaling подписи ещё не доделан.`,
'info',
);
return {
ok: true,
pending: true,
};
}
function snapshot() {
return {
settings: { ...state.settings },
pairing: {
active: !!state.pairingId,
pairingId: state.pairingId,
expiresAtMs: state.expiresAtMs,
shortCode: state.shortCode,
trustedSessionOnline: state.trustedSessionOnline,
},
session: state.activeSession ? { ...state.activeSession } : null,
connectionOnline: state.connectionOnline,
walletProfile: state.walletProfile ? { ...state.walletProfile } : null,
signing: { ...state.signing },
status: {
text: state.statusText,
kind: state.statusKind,
},
};
}
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
(async () => {
const type = String(message?.type || '');
if (type === 'wallet:getState') {
await loadStateFromStorage();
sendResponse({ ok: true, state: snapshot() });
return;
}
if (type === 'wallet:saveSettings') {
await persistSettings(message?.payload || {});
sendResponse({ ok: true, state: snapshot() });
return;
}
if (type === 'wallet:startPairing') {
const result = await startPairing(message?.payload || {});
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:cancelPairing') {
const result = await cancelPairing();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:resumeSession') {
const result = await resumeActiveSession();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:refreshWalletDevices') {
const result = await refreshWalletDevices();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:updateSigningSelection') {
const result = await updateSigningSelection(message?.payload || {});
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:prepareSignSignal') {
const result = await prepareSignSignal();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:disconnectSession') {
const result = await disconnectSession();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
sendResponse({ ok: false, error: 'UNKNOWN_MESSAGE' });
})().catch((error) => {
setStatus(error?.message || 'Unknown error', 'error');
sendResponse({ ok: false, error: error?.message || 'Unknown error', state: snapshot() });
});
return true;
});
void loadStateFromStorage().then(async () => {
if (state.activeSession?.login) {
await hydrateWalletProfile(state.activeSession.login).catch(() => {});
setStatus(`Wallet-session сохранена для @${state.activeSession.login}. Подключение будет открываться только по действию.`, 'info');
}
}).catch((error) => {
setStatus(error?.message || 'Не удалось инициализировать wallet plugin.', 'error');
});