Добавить SendSignal и remote AddBlock через homeserver

This commit is contained in:
AidarKC
2026-06-28 11:20:51 +04:00
parent c397c28acb
commit aa02e92e4d
19 changed files with 1329 additions and 224 deletions
+2
View File
@@ -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,
+1 -1
View File
@@ -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;
}
+2
View File
@@ -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'));
+1
View File
@@ -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' ||
+303 -181
View File
@@ -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 || {};
}
+10
View File
@@ -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;
}