SHA256
Добавить SendSignal и remote AddBlock через homeserver
This commit is contained in:
@@ -55,6 +55,7 @@ import * as settingsView from './pages/settings-view.js';
|
||||
import * as developerSettingsView from './pages/developer-settings-view.js';
|
||||
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
|
||||
import * as toolsSettingsView from './pages/tools-settings-view.js';
|
||||
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
|
||||
import * as deviceView from './pages/device-view.js?v=202606131435';
|
||||
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
|
||||
import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940';
|
||||
@@ -101,6 +102,7 @@ const routes = {
|
||||
'developer-settings-view': developerSettingsView,
|
||||
'server-settings-view': serverSettingsView,
|
||||
'tools-settings-view': toolsSettingsView,
|
||||
'remote-addblock-session-view': remoteAddBlockSessionView,
|
||||
'device-view': deviceView,
|
||||
'connect-device-view': connectDeviceView,
|
||||
'device-pairing-view': clientPairingView,
|
||||
|
||||
@@ -205,7 +205,7 @@ export function render({ navigate }) {
|
||||
<span class="field-label">Активные заявки</span>
|
||||
<button class="ghost-btn" type="button" id="refresh-pairing-requests">Обновить заявки</button>
|
||||
</div>
|
||||
<p class="meta-muted">Показываются все активные заявки. Для wallet-заявки можно выпустить отдельную session-only сессию без передачи постоянных ключей.</p>
|
||||
<p class="meta-muted">Показываются все активные заявки. Для wallet-заявки выпускается отдельная session-only сессия без передачи постоянных ключей.</p>
|
||||
<div class="stack" id="pairing-requests-list"></div>
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import {
|
||||
isSessionInvalidError,
|
||||
refreshSessions,
|
||||
saveEntrySettings,
|
||||
setAuthError,
|
||||
setAuthInfo,
|
||||
state,
|
||||
terminateCurrentSession,
|
||||
} from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'remote-addblock-session-view', title: 'AddBlock через homeserver' };
|
||||
|
||||
const SESSION_TYPE_HOMESERVER = 100;
|
||||
|
||||
function formatSessionTime(ms) {
|
||||
return new Date(ms).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function sessionLabel(session) {
|
||||
const title = String(session?.clientInfoFromClient || '').trim();
|
||||
if (title) return title;
|
||||
return `Homeserver ${String(session?.sessionId || '').slice(0, 12)}`;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'AddBlock через homeserver',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'card stack';
|
||||
actions.innerHTML = `
|
||||
<p class="meta-muted">Если на устройстве нет локального blockchain key, UI сможет отправлять AddBlock в выбранную homeserver-сессию. Homeserver подпишет блок своим blockchain key и сам отправит обычный AddBlock на сервер.</p>
|
||||
<button class="primary-btn" type="button" id="remote-addblock-refresh">Обновить список homeserver-сессий</button>
|
||||
<button class="ghost-btn" type="button" id="remote-addblock-clear">Сбросить выбор</button>
|
||||
`;
|
||||
|
||||
const listCard = document.createElement('div');
|
||||
listCard.className = 'card stack';
|
||||
|
||||
const buildList = () => {
|
||||
listCard.innerHTML = '';
|
||||
const selectedId = String(state.entrySettings.remoteAddBlockSessionId || '').trim();
|
||||
const sessions = (state.sessions || [])
|
||||
.filter((item) => Number(item?.sessionType || 0) === SESSION_TYPE_HOMESERVER)
|
||||
.sort((a, b) => Number(b?.lastAuthenticatedAtMs || 0) - Number(a?.lastAuthenticatedAtMs || 0));
|
||||
|
||||
if (sessions.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'meta-muted';
|
||||
empty.textContent = 'Активных homeserver-сессий не найдено.';
|
||||
listCard.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
sessions.forEach((session) => {
|
||||
const sessionId = String(session?.sessionId || '').trim();
|
||||
const item = document.createElement('button');
|
||||
item.className = 'session-item';
|
||||
item.type = 'button';
|
||||
const isSelected = sessionId && sessionId === selectedId;
|
||||
item.innerHTML = `
|
||||
<div class="stack" style="gap:4px; text-align:left;">
|
||||
<strong>${sessionLabel(session)}</strong>
|
||||
<span class="meta-muted">${session.geo || 'unknown'} · ${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>
|
||||
<span class="meta-muted">sessionId: ${sessionId || '-'}</span>
|
||||
<span class="meta-muted">status: ${session.onlineOnThisServer ? 'online' : 'offline on this server'}</span>
|
||||
${isSelected ? '<span class="session-current-badge">Выбрана для remote AddBlock</span>' : ''}
|
||||
</div>
|
||||
`;
|
||||
item.addEventListener('click', async () => {
|
||||
try {
|
||||
await saveEntrySettings({
|
||||
...state.entrySettings,
|
||||
remoteAddBlockSessionId: sessionId,
|
||||
});
|
||||
buildList();
|
||||
setAuthInfo('Homeserver-сессия для remote AddBlock сохранена.');
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
}
|
||||
});
|
||||
listCard.append(item);
|
||||
});
|
||||
};
|
||||
|
||||
actions.querySelector('#remote-addblock-refresh')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await refreshSessions();
|
||||
buildList();
|
||||
setAuthInfo('Список homeserver-сессий обновлён.');
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
actions.querySelector('#remote-addblock-clear')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await saveEntrySettings({
|
||||
...state.entrySettings,
|
||||
remoteAddBlockSessionId: '',
|
||||
});
|
||||
buildList();
|
||||
setAuthInfo('Выбор homeserver-сессии для remote AddBlock сброшен.');
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
buildList();
|
||||
screen.append(actions, listCard);
|
||||
return screen;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export function render({ navigate }) {
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<button class="text-btn" type="button" id="settings-device">Устройства</button>
|
||||
<button class="text-btn" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
|
||||
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
|
||||
<button class="text-btn" type="button" id="settings-tools">Настройки инструментов ввода</button>
|
||||
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
|
||||
@@ -48,6 +49,7 @@ export function render({ navigate }) {
|
||||
`;
|
||||
|
||||
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
|
||||
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
|
||||
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
|
||||
card.querySelector('#settings-tools').addEventListener('click', () => navigate('tools-settings-view'));
|
||||
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
|
||||
|
||||
@@ -178,6 +178,7 @@ export function resolveToolbarActive(pageId) {
|
||||
pageId === 'developer-settings-view' ||
|
||||
pageId === 'server-settings-view' ||
|
||||
pageId === 'tools-settings-view' ||
|
||||
pageId === 'remote-addblock-session-view' ||
|
||||
pageId === 'device-view' ||
|
||||
pageId === 'connect-device-view' ||
|
||||
pageId === 'device-pairing-view' ||
|
||||
|
||||
@@ -56,6 +56,11 @@ const CHANNEL_TYPE_GROUP = 200;
|
||||
const CHANNEL_TYPE_VERSION_DEFAULT = 1;
|
||||
const SESSION_TYPE_CLIENT = 1;
|
||||
const SESSION_TYPE_WALLET = 50;
|
||||
const SESSION_TYPE_HOMESERVER = 100;
|
||||
const SIGNAL_TARGET_SINGLE = 'single_session';
|
||||
const SIGNAL_TARGET_ALL = 'all_sessions';
|
||||
const SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST = 'remote_addblock_request';
|
||||
const SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT = 'remote_addblock_result';
|
||||
|
||||
const CONNECTION_SUBTYPES = Object.freeze({
|
||||
// Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11.
|
||||
@@ -108,6 +113,10 @@ function opError(op, response) {
|
||||
return error;
|
||||
}
|
||||
|
||||
function createSignalRequestId(prefix = 'signal') {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function makeClientInfo() {
|
||||
const ua = navigator.userAgent || 'unknown';
|
||||
return ua.slice(0, 50);
|
||||
@@ -137,6 +146,11 @@ function normalizeHex32(value, fallback = ZERO64) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function sha256Base64FromText(text) {
|
||||
const digest = await sha256Bytes(utf8Bytes(String(text || '')));
|
||||
return bytesToBase64(digest);
|
||||
}
|
||||
|
||||
function concatBytes(...chunks) {
|
||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
@@ -745,6 +759,9 @@ export class AuthService {
|
||||
this.writeLocks = new Map();
|
||||
this.passwordKeyBundleCache = new Map();
|
||||
this.passwordKeyBundleInFlight = new Map();
|
||||
this.currentLogin = '';
|
||||
this.currentSessionId = '';
|
||||
this.remoteAddBlockSessionId = '';
|
||||
}
|
||||
|
||||
async reconnect(serverUrl) {
|
||||
@@ -757,6 +774,20 @@ export class AuthService {
|
||||
this.writeLocks.clear();
|
||||
}
|
||||
|
||||
setActiveSessionContext({ login = '', sessionId = '' } = {}) {
|
||||
this.currentLogin = String(login || '').trim();
|
||||
this.currentSessionId = String(sessionId || '').trim();
|
||||
}
|
||||
|
||||
clearActiveSessionContext() {
|
||||
this.currentLogin = '';
|
||||
this.currentSessionId = '';
|
||||
}
|
||||
|
||||
setRemoteAddBlockSessionId(sessionId = '') {
|
||||
this.remoteAddBlockSessionId = String(sessionId || '').trim();
|
||||
}
|
||||
|
||||
runWriteLocked(lockKey, runAction) {
|
||||
const key = String(lockKey || '').trim() || 'write';
|
||||
if (this.writeLocks.has(key)) return this.writeLocks.get(key);
|
||||
@@ -1096,6 +1127,100 @@ export class AuthService {
|
||||
return response?.payload?.sessions || [];
|
||||
}
|
||||
|
||||
async waitForSignal({ signalType, signalRequestId, timeoutMs = 15000 }) {
|
||||
const cleanSignalType = String(signalType || '').trim();
|
||||
const cleanSignalRequestId = String(signalRequestId || '').trim();
|
||||
if (!cleanSignalType || !cleanSignalRequestId) {
|
||||
throw new Error('waitForSignal: не переданы signalType/signalRequestId');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let unsubscribe = () => {};
|
||||
const timer = window.setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error(`Таймаут ожидания сигнала ${cleanSignalType}`));
|
||||
}, timeoutMs);
|
||||
|
||||
unsubscribe = this.onEvent('IncomingSignal', (evt) => {
|
||||
const payload = evt?.payload || {};
|
||||
if (String(payload?.signalType || '').trim() !== cleanSignalType) return;
|
||||
if (String(payload?.signalRequestId || '').trim() !== cleanSignalRequestId) return;
|
||||
window.clearTimeout(timer);
|
||||
unsubscribe();
|
||||
resolve(payload);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async sendSignal({
|
||||
toLogin,
|
||||
targetMode = SIGNAL_TARGET_SINGLE,
|
||||
targetSessionId = '',
|
||||
signalType,
|
||||
signalRequestId,
|
||||
data = '',
|
||||
storagePwd = '',
|
||||
includeClientSignature = true,
|
||||
timeMs = Date.now(),
|
||||
} = {}) {
|
||||
const cleanToLogin = String(toLogin || '').trim();
|
||||
const cleanTargetMode = String(targetMode || '').trim();
|
||||
const cleanTargetSessionId = String(targetSessionId || '').trim();
|
||||
const cleanSignalType = String(signalType || '').trim();
|
||||
const cleanSignalRequestId = String(signalRequestId || '').trim();
|
||||
const cleanLogin = String(this.currentLogin || '').trim();
|
||||
const cleanSessionId = String(this.currentSessionId || '').trim();
|
||||
if (!cleanLogin || !cleanSessionId) throw new Error('SendSignal: нет активного login/sessionId');
|
||||
if (!cleanToLogin || !cleanSignalType || !cleanSignalRequestId) {
|
||||
throw new Error('SendSignal: не переданы toLogin/signalType/signalRequestId');
|
||||
}
|
||||
if (cleanTargetMode !== SIGNAL_TARGET_SINGLE && cleanTargetMode !== SIGNAL_TARGET_ALL) {
|
||||
throw new Error('SendSignal: bad targetMode');
|
||||
}
|
||||
if (cleanTargetMode === SIGNAL_TARGET_SINGLE && !cleanTargetSessionId) {
|
||||
throw new Error('SendSignal: targetSessionId обязателен для single_session');
|
||||
}
|
||||
|
||||
const sessionMaterial = await loadSessionMaterial(cleanLogin);
|
||||
if (!sessionMaterial?.sessionPrivPkcs8) {
|
||||
throw new Error('На устройстве нет сохранённого session key для SendSignal');
|
||||
}
|
||||
const sessionPrivateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8);
|
||||
|
||||
const dataText = typeof data === 'string' ? data : JSON.stringify(data || {});
|
||||
const dataSha256B64 = await sha256Base64FromText(dataText);
|
||||
|
||||
const sessionPreimage = `SEND_SIGNAL_SESSION:${cleanLogin}:${cleanSessionId}:${cleanToLogin}:${cleanTargetMode}:${cleanTargetSessionId}:${cleanSignalType}:${cleanSignalRequestId}:${Number(timeMs)}:${dataSha256B64}`;
|
||||
const sessionSignatureB64 = await signBase64(sessionPrivateKey, sessionPreimage);
|
||||
|
||||
let clientSignatureB64 = '';
|
||||
if (includeClientSignature) {
|
||||
if (!storagePwd) throw new Error('SendSignal: нужен storagePwd для подписи client key');
|
||||
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
const clientPrivatePkcs8 = String(secrets?.clientKey || '').trim();
|
||||
if (!clientPrivatePkcs8) {
|
||||
throw new Error('На устройстве нет сохранённого client key для SendSignal');
|
||||
}
|
||||
const clientPrivateKey = await importPkcs8Ed25519(clientPrivatePkcs8);
|
||||
const clientPreimage = `SEND_SIGNAL_CLIENT:${cleanLogin}:${cleanSessionId}:${cleanToLogin}:${cleanTargetMode}:${cleanTargetSessionId}:${cleanSignalType}:${cleanSignalRequestId}:${Number(timeMs)}:${dataSha256B64}`;
|
||||
clientSignatureB64 = await signBase64(clientPrivateKey, clientPreimage);
|
||||
}
|
||||
|
||||
const response = await this.ws.request('SendSignal', {
|
||||
toLogin: cleanToLogin,
|
||||
targetMode: cleanTargetMode,
|
||||
targetSessionId: cleanTargetMode === SIGNAL_TARGET_SINGLE ? cleanTargetSessionId : '',
|
||||
signalType: cleanSignalType,
|
||||
signalRequestId: cleanSignalRequestId,
|
||||
data: dataText,
|
||||
timeMs: Number(timeMs),
|
||||
sessionSignatureB64,
|
||||
clientSignatureB64,
|
||||
});
|
||||
if (response.status !== 200) throw opError('SendSignal', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async closeSession(sessionId) {
|
||||
const response = await this.ws.request('CloseActiveSession', { sessionId });
|
||||
if (response.status !== 200) throw opError('CloseActiveSession', response);
|
||||
@@ -1219,56 +1344,115 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login for AddBlock');
|
||||
if (!storagePwd) throw new Error('Missing storagePwd for AddBlock signing');
|
||||
|
||||
const resolveFreshCursor = async () => {
|
||||
const user = await this.getUser(cleanLogin);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const freshNum = Number(user?.serverLastGlobalNumber);
|
||||
const freshHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
|
||||
return {
|
||||
blockchainName,
|
||||
cursor: {
|
||||
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
|
||||
serverLastGlobalHash: freshHash,
|
||||
},
|
||||
};
|
||||
async resolveFreshBlockchainCursor(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const user = await this.getUser(cleanLogin);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const freshNum = Number(user?.serverLastGlobalNumber);
|
||||
const freshHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
|
||||
return {
|
||||
blockchainName,
|
||||
cursor: {
|
||||
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
|
||||
serverLastGlobalHash: freshHash,
|
||||
},
|
||||
};
|
||||
const freshState = await resolveFreshCursor();
|
||||
const blockchainName = freshState.blockchainName;
|
||||
}
|
||||
|
||||
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
const blockchainPrivatePkcs8 = savedKeys?.blockchainKey;
|
||||
if (!blockchainPrivatePkcs8) {
|
||||
throw new Error('Missing saved blockchain private key on device');
|
||||
async submitPreparedAddBlock({ login, storagePwd, blockchainName, blockNumber, prevBlockHash, preimage }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanBlockchainName = String(blockchainName || '').trim();
|
||||
const cleanPrevBlockHash = normalizeHex32(prevBlockHash, ZERO_HASH_HEX);
|
||||
if (!cleanLogin || !cleanBlockchainName) throw new Error('submitPreparedAddBlock: missing login/blockchainName');
|
||||
if (!(preimage instanceof Uint8Array) || preimage.length === 0) {
|
||||
throw new Error('submitPreparedAddBlock: bad preimage');
|
||||
}
|
||||
|
||||
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
|
||||
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
const blockchainPrivatePkcs8 = String(savedKeys?.blockchainKey || '').trim();
|
||||
if (blockchainPrivatePkcs8) {
|
||||
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
|
||||
const hash32 = await sha256Bytes(preimage);
|
||||
const signatureBytes = await signBytes(privateKey, hash32);
|
||||
const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);
|
||||
return this.ws.request('AddBlock', {
|
||||
blockchainName: cleanBlockchainName,
|
||||
blockNumber: Number(blockNumber),
|
||||
prevBlockHash: cleanPrevBlockHash,
|
||||
blockBytesB64: bytesToBase64(fullBlock),
|
||||
});
|
||||
}
|
||||
|
||||
const remoteSessionId = String(this.remoteAddBlockSessionId || '').trim();
|
||||
if (!remoteSessionId) {
|
||||
throw new Error('На устройстве нет blockchain key и не выбрана homeserver-сессия для remote AddBlock');
|
||||
}
|
||||
|
||||
const signalRequestId = createSignalRequestId('remote-addblock');
|
||||
const responseWait = this.waitForSignal({
|
||||
signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT,
|
||||
signalRequestId,
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
|
||||
const signalData = {
|
||||
operation: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST,
|
||||
signalRequestId,
|
||||
login: cleanLogin,
|
||||
blockchainName: cleanBlockchainName,
|
||||
blockNumber: Number(blockNumber),
|
||||
prevBlockHash: cleanPrevBlockHash,
|
||||
blockPreimageB64: bytesToBase64(preimage),
|
||||
};
|
||||
|
||||
await this.sendSignal({
|
||||
toLogin: cleanLogin,
|
||||
targetMode: SIGNAL_TARGET_SINGLE,
|
||||
targetSessionId: remoteSessionId,
|
||||
signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST,
|
||||
signalRequestId,
|
||||
data: JSON.stringify(signalData),
|
||||
storagePwd,
|
||||
includeClientSignature: true,
|
||||
});
|
||||
|
||||
const signalPayload = await responseWait;
|
||||
let result = {};
|
||||
try {
|
||||
result = JSON.parse(String(signalPayload?.data || '{}'));
|
||||
} catch {
|
||||
throw new Error('Некорректный ответ remote AddBlock от homeserver');
|
||||
}
|
||||
if (!result?.ok) {
|
||||
throw new Error(String(result?.errorMessage || result?.error || 'remote_addblock_failed'));
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
payload: {
|
||||
serverLastGlobalNumber: Number(result?.serverLastGlobalNumber ?? blockNumber),
|
||||
serverLastGlobalHash: String(result?.serverLastGlobalHash || ZERO_HASH_HEX),
|
||||
remote: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async runAddBlockWithRetry({ login, storagePwd, resolveFreshState, buildPreimage }) {
|
||||
let freshState = await resolveFreshState();
|
||||
let blockchainName = String(freshState?.blockchainName || '').trim();
|
||||
if (!blockchainName) throw new Error('runAddBlockWithRetry: blockchainName is empty');
|
||||
|
||||
const tryAdd = async (cursor) => {
|
||||
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
|
||||
const prevBlockHash = normalizeHex32(cursor?.serverLastGlobalHash, ZERO64);
|
||||
const preimage = buildBlockPreimage({
|
||||
prevBlockHashHex: prevBlockHash,
|
||||
blockNumber,
|
||||
msgType,
|
||||
msgSubType,
|
||||
msgVersion,
|
||||
bodyBytes,
|
||||
});
|
||||
|
||||
const hash32 = await sha256Bytes(preimage);
|
||||
const signatureBytes = await signBytes(privateKey, hash32);
|
||||
const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);
|
||||
|
||||
return this.ws.request('AddBlock', {
|
||||
const preimage = await buildPreimage({ blockNumber, prevBlockHash, blockchainName });
|
||||
return this.submitPreparedAddBlock({
|
||||
login,
|
||||
storagePwd,
|
||||
blockchainName,
|
||||
blockNumber,
|
||||
prevBlockHash,
|
||||
blockBytesB64: bytesToBase64(fullBlock),
|
||||
preimage,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1281,13 +1465,38 @@ export class AuthService {
|
||||
cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash.toLowerCase() };
|
||||
response = await tryAdd(cursor);
|
||||
} else {
|
||||
const refreshed = await resolveFreshCursor();
|
||||
cursor = refreshed.cursor;
|
||||
freshState = await resolveFreshState();
|
||||
blockchainName = String(freshState?.blockchainName || blockchainName).trim() || blockchainName;
|
||||
cursor = freshState.cursor;
|
||||
response = await tryAdd(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status !== 200) throw opError('AddBlock', response);
|
||||
return {
|
||||
response,
|
||||
blockchainName,
|
||||
};
|
||||
}
|
||||
|
||||
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login for AddBlock');
|
||||
if (!storagePwd) throw new Error('Missing storagePwd for AddBlock signing');
|
||||
|
||||
const { response, blockchainName } = await this.runAddBlockWithRetry({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
resolveFreshState: () => this.resolveFreshBlockchainCursor(cleanLogin),
|
||||
buildPreimage: async ({ blockNumber, prevBlockHash }) => buildBlockPreimage({
|
||||
prevBlockHashHex: prevBlockHash,
|
||||
blockNumber,
|
||||
msgType,
|
||||
msgSubType,
|
||||
msgVersion,
|
||||
bodyBytes,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = response.payload || {};
|
||||
const acceptedNum = Number(payload?.serverLastGlobalNumber);
|
||||
@@ -2249,78 +2458,32 @@ export class AuthService {
|
||||
if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param.');
|
||||
if (!cleanValue) throw new Error('Значение параметра не может быть пустым.');
|
||||
if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.');
|
||||
|
||||
const user = await this.getUser(cleanLogin);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const freshNum = Number(user?.serverLastGlobalNumber);
|
||||
const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase();
|
||||
const freshCursor = {
|
||||
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
|
||||
serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX,
|
||||
};
|
||||
|
||||
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
const blockchainPrivatePkcs8 = savedKeys?.blockchainKey;
|
||||
if (!blockchainPrivatePkcs8) {
|
||||
throw new Error('На устройстве нет сохраненного приватного blockchainKey');
|
||||
}
|
||||
|
||||
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
|
||||
|
||||
const tryAdd = async (cursor) => {
|
||||
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
|
||||
const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX);
|
||||
|
||||
// Для USER_PARAM отправляем старт новой line-цепочки:
|
||||
// prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1.
|
||||
// Этот формат соответствует BodyHasLine правилам на сервере.
|
||||
const bodyBytes = makeUserParamBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber: -1,
|
||||
prevLineHashHex: ZERO_HASH_HEX,
|
||||
thisLineNumber: -1,
|
||||
key: cleanParam,
|
||||
value: cleanValue,
|
||||
});
|
||||
|
||||
const preimage = concatBytes(
|
||||
int16Bytes(0),
|
||||
hexToBytes(prevBlockHash),
|
||||
int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
|
||||
int32Bytes(blockNumber),
|
||||
int64Bytes(Math.floor(Date.now() / 1000)),
|
||||
int16Bytes(4),
|
||||
int16Bytes(1),
|
||||
int16Bytes(1),
|
||||
bodyBytes,
|
||||
);
|
||||
|
||||
const hash32 = await sha256Bytes(preimage);
|
||||
const signatureBytes = await signBytes(privateKey, hash32);
|
||||
const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);
|
||||
|
||||
const response = await this.ws.request('AddBlock', {
|
||||
blockchainName,
|
||||
blockNumber,
|
||||
prevBlockHash,
|
||||
blockBytesB64: bytesToBase64(fullBlock),
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
let cursor = freshCursor;
|
||||
let response = await tryAdd(cursor);
|
||||
if (response.status !== 200) {
|
||||
const knownNum = Number(response?.payload?.serverLastGlobalNumber);
|
||||
const knownHash = String(response?.payload?.serverLastGlobalHash || '');
|
||||
if (Number.isFinite(knownNum) && knownHash.length === 64) {
|
||||
cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash };
|
||||
response = await tryAdd(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status !== 200) throw opError('AddBlock', response);
|
||||
const { response } = await this.runAddBlockWithRetry({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
resolveFreshState: () => this.resolveFreshBlockchainCursor(cleanLogin),
|
||||
buildPreimage: async ({ blockNumber, prevBlockHash }) => {
|
||||
const bodyBytes = makeUserParamBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber: -1,
|
||||
prevLineHashHex: ZERO_HASH_HEX,
|
||||
thisLineNumber: -1,
|
||||
key: cleanParam,
|
||||
value: cleanValue,
|
||||
});
|
||||
return concatBytes(
|
||||
int16Bytes(0),
|
||||
hexToBytes(prevBlockHash),
|
||||
int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
|
||||
int32Bytes(blockNumber),
|
||||
int64Bytes(Math.floor(Date.now() / 1000)),
|
||||
int16Bytes(4),
|
||||
int16Bytes(1),
|
||||
int16Bytes(1),
|
||||
bodyBytes,
|
||||
);
|
||||
},
|
||||
});
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
@@ -2337,78 +2500,37 @@ export class AuthService {
|
||||
|
||||
const user = await this.getUser(cleanLogin);
|
||||
if (user?.exists === false) throw new Error('Текущий пользователь не найден.');
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const freshNum = Number(user?.serverLastGlobalNumber);
|
||||
const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase();
|
||||
const freshCursor = {
|
||||
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
|
||||
serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX,
|
||||
};
|
||||
|
||||
const targetUser = await this.getUser(cleanToLogin);
|
||||
if (!targetUser?.exists) throw new Error('Пользователь цели не найден.');
|
||||
const toBlockchainName = String(targetUser?.blockchainName || `${cleanToLogin}-${BCH_SUFFIX}`).trim();
|
||||
|
||||
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
const blockchainPrivatePkcs8 = savedKeys?.blockchainKey;
|
||||
if (!blockchainPrivatePkcs8) {
|
||||
throw new Error('На устройстве нет сохраненного приватного blockchainKey');
|
||||
}
|
||||
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
|
||||
|
||||
const tryAdd = async (cursor) => {
|
||||
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
|
||||
const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX);
|
||||
|
||||
// Для CONNECTION в UI-MVP всегда стартуем новую line-цепочку:
|
||||
// prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1.
|
||||
// target для user-связей указывает на HEADER пользователя (blockNumber=0).
|
||||
const bodyBytes = makeConnectionBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber: -1,
|
||||
prevLineHashHex: ZERO_HASH_HEX,
|
||||
thisLineNumber: -1,
|
||||
toBlockchainName,
|
||||
toBlockNumber: 0,
|
||||
toBlockHashHex: ZERO_HASH_HEX,
|
||||
});
|
||||
|
||||
const preimage = concatBytes(
|
||||
int16Bytes(0),
|
||||
hexToBytes(prevBlockHash),
|
||||
int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
|
||||
int32Bytes(blockNumber),
|
||||
int64Bytes(Math.floor(Date.now() / 1000)),
|
||||
int16Bytes(3),
|
||||
int16Bytes(cleanSubType),
|
||||
int16Bytes(1),
|
||||
bodyBytes,
|
||||
);
|
||||
|
||||
const hash32 = await sha256Bytes(preimage);
|
||||
const signatureBytes = await signBytes(privateKey, hash32);
|
||||
const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);
|
||||
|
||||
return this.ws.request('AddBlock', {
|
||||
blockchainName,
|
||||
blockNumber,
|
||||
prevBlockHash,
|
||||
blockBytesB64: bytesToBase64(fullBlock),
|
||||
});
|
||||
};
|
||||
|
||||
let cursor = freshCursor;
|
||||
let response = await tryAdd(cursor);
|
||||
if (response.status !== 200) {
|
||||
const knownNum = Number(response?.payload?.serverLastGlobalNumber);
|
||||
const knownHash = String(response?.payload?.serverLastGlobalHash || '').trim().toLowerCase();
|
||||
if (Number.isFinite(knownNum) && knownHash.length === 64) {
|
||||
cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash };
|
||||
response = await tryAdd(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status !== 200) throw opError('AddBlock', response);
|
||||
const { response } = await this.runAddBlockWithRetry({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
resolveFreshState: () => this.resolveFreshBlockchainCursor(cleanLogin),
|
||||
buildPreimage: async ({ blockNumber, prevBlockHash }) => {
|
||||
const bodyBytes = makeConnectionBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber: -1,
|
||||
prevLineHashHex: ZERO_HASH_HEX,
|
||||
thisLineNumber: -1,
|
||||
toBlockchainName,
|
||||
toBlockNumber: 0,
|
||||
toBlockHashHex: ZERO_HASH_HEX,
|
||||
});
|
||||
return concatBytes(
|
||||
int16Bytes(0),
|
||||
hexToBytes(prevBlockHash),
|
||||
int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
|
||||
int32Bytes(blockNumber),
|
||||
int64Bytes(Math.floor(Date.now() / 1000)),
|
||||
int16Bytes(3),
|
||||
int16Bytes(cleanSubType),
|
||||
int16Bytes(1),
|
||||
bodyBytes,
|
||||
);
|
||||
},
|
||||
});
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
|
||||
@@ -188,6 +188,7 @@ function persistEntrySettings(settings) {
|
||||
shineServerHttp: String(settings?.shineServerHttp || DEFAULT_SHINE_SERVER_HTTP_VALUE),
|
||||
arweaveServer: String(settings?.arweaveServer || DEFAULT_ARWEAVE_SERVER),
|
||||
callPreflightTimeoutMs: Math.max(1000, Math.min(20000, Number(settings?.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS) || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS)),
|
||||
remoteAddBlockSessionId: String(settings?.remoteAddBlockSessionId || ''),
|
||||
statuses: {
|
||||
solanaServer: String(settings?.statuses?.solanaServer || 'idle'),
|
||||
shineServerLogin: String(settings?.statuses?.shineServerLogin || settings?.statuses?.shineServer || 'idle'),
|
||||
@@ -254,6 +255,7 @@ function createInitialState({ withStoredSession = true } = {}) {
|
||||
shineServerHttp: String(storedEntrySettings?.shineServerHttp || DEFAULT_SHINE_SERVER_HTTP_VALUE),
|
||||
arweaveServer: String(storedEntrySettings?.arweaveServer || DEFAULT_ARWEAVE_SERVER),
|
||||
callPreflightTimeoutMs: Math.max(1000, Math.min(20000, Number(storedEntrySettings?.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS) || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS)),
|
||||
remoteAddBlockSessionId: String(storedEntrySettings?.remoteAddBlockSessionId || ''),
|
||||
statuses: {
|
||||
solanaServer: String(storedEntrySettings?.statuses?.solanaServer || 'idle'),
|
||||
shineServerLogin: String(storedEntrySettings?.statuses?.shineServerLogin || storedEntrySettings?.statuses?.shineServer || 'idle'),
|
||||
@@ -316,6 +318,11 @@ function createInitialState({ withStoredSession = true } = {}) {
|
||||
export const state = createInitialState();
|
||||
|
||||
export const authService = new AuthService(state.entrySettings.shineServer);
|
||||
authService.setRemoteAddBlockSessionId(state.entrySettings.remoteAddBlockSessionId);
|
||||
authService.setActiveSessionContext({
|
||||
login: state.session.login,
|
||||
sessionId: state.session.sessionId,
|
||||
});
|
||||
let onSessionReset = null;
|
||||
let onSessionAuthorized = null;
|
||||
|
||||
@@ -738,6 +745,7 @@ export async function saveEntrySettings(nextSettings) {
|
||||
tools: normalizeToolsSettings(nextSettings.tools || state.entrySettings.tools),
|
||||
};
|
||||
persistEntrySettings(state.entrySettings);
|
||||
authService.setRemoteAddBlockSessionId(state.entrySettings.remoteAddBlockSessionId);
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
state.startHint = `Настройки входа сохранены. SHiNE: ${state.entrySettings.shineServerHttp}`;
|
||||
}
|
||||
@@ -777,6 +785,7 @@ export function authorizeSession({
|
||||
login,
|
||||
sessionId,
|
||||
});
|
||||
authService.setActiveSessionContext({ login, sessionId });
|
||||
state.startHint = '';
|
||||
if (onSessionAuthorized) {
|
||||
onSessionAuthorized();
|
||||
@@ -851,6 +860,7 @@ export async function terminateCurrentSession({ infoMessage = '', closeServerSes
|
||||
resetStateForSignedOut();
|
||||
await clearStoredMessages().catch(() => {});
|
||||
authService.close();
|
||||
authService.clearActiveSessionContext();
|
||||
if (infoMessage) {
|
||||
state.startHint = infoMessage;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user