Добавить 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
+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 || {};
}