НЕ ПРОВЕРЕНО: откат DM-вложений, оставлены ревизии и удаление

This commit is contained in:
AidarKC
2026-06-18 12:24:14 +04:00
parent 92fd315505
commit a95bd245cf
23 changed files with 309 additions and 1267 deletions
+7 -173
View File
@@ -1,19 +1,14 @@
import { WsJsonClient } from './ws-client.js';
import {
base64ToBytes,
base64UrlToBytes,
bytesToBase64,
bytesToBase64Url,
decryptBytesAesGcm,
deriveEd25519FromMasterSecret,
deriveMasterSecretFromPassword,
encryptBytesAesGcm,
exportEd25519PublicKeyB64,
exportPkcs8B64,
generateEd25519Pair,
importPkcs8Ed25519,
publicKeyB64FromPkcs8Ed25519,
randomBytes,
randomBase64,
sha256Bytes,
signBytes,
@@ -212,7 +207,6 @@ const DM2_TYPE_READ_INCOMING = 3;
const DM2_TYPE_READ_OUTGOING_COPY = 4;
const DM_FORMAT_VERSION_MAJOR = 1;
const DM_FORMAT_VERSION_MINOR = 0;
const DM_MAX_ATTACHMENTS = 12;
const DM_MAX_ENCRYPTED_BODY_BYTES = 16384;
function ensureAsciiBytes(value, field, min = 1, max = 60) {
@@ -248,51 +242,6 @@ function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, ref
);
}
function buildDmAttachmentsSectionBytes(attachments = []) {
const list = Array.isArray(attachments) ? attachments : [];
if (list.length > DM_MAX_ATTACHMENTS) {
throw new Error(`Вложений должно быть не больше ${DM_MAX_ATTACHMENTS}`);
}
const parts = [uint8Bytes(list.length)];
list.forEach((item, index) => {
const hashB64u = String(item?.encHashB64u || '').trim();
const hashBytes = base64UrlToBytes(hashB64u);
if (hashBytes.length !== 32) throw new Error(`Некорректный encHash у вложения #${index + 1}`);
const encSize = Number(item?.encSize ?? item?.encFileSize ?? 0);
if (!Number.isFinite(encSize) || encSize < 0) throw new Error(`Некорректный encSize у вложения #${index + 1}`);
parts.push(hashBytes, uint64Bytes(encSize));
});
return concatBytes(...parts);
}
function parseDmTextAttachments(text) {
const raw = String(text || '');
const regex = /<<file:file-format\(1\.0\):([^>|]+)\|([^>|]+)\|(\d+)\|([^>|]+)\|([^>|]+)\|(\d+)\|([^>|]+)\|([^>|]+)>>/g;
const attachments = [];
let cleaned = '';
let lastIndex = 0;
let match;
while ((match = regex.exec(raw)) !== null) {
cleaned += raw.slice(lastIndex, match.index);
lastIndex = match.index + match[0].length;
attachments.push({
type: String(match[1] || 'file'),
fileName: String(match[2] || ''),
origSize: Number(match[3] || 0),
origHashB64u: String(match[4] || ''),
encHashB64u: String(match[5] || ''),
encSize: Number(match[6] || 0),
keyB64u: String(match[7] || ''),
nonceB64u: String(match[8] || ''),
});
}
cleaned += raw.slice(lastIndex);
return {
text: cleaned.replace(/\n{3,}/g, '\n\n').trim(),
attachments,
};
}
function parseSignedMessageBlockBytes(bytes) {
if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array');
let o = 0;
@@ -348,17 +297,7 @@ function parseSignedMessageBlockBytes(bytes) {
const messageType = readU16();
const revisionTimeMs = readU64();
const attachmentsCount = readU8();
if (attachmentsCount > DM_MAX_ATTACHMENTS) throw new Error('TOO_MANY_ATTACHMENTS');
const attachments = [];
for (let i = 0; i < attachmentsCount; i += 1) {
const hashBytes = read(32);
const encSize = readU64();
attachments.push({
encHashBytes: hashBytes,
encHashB64u: bytesToBase64Url(hashBytes),
encSize,
});
}
if (attachmentsCount !== 0) throw new Error('ATTACHMENTS_DISABLED');
const encryptedBodyLen = readU32();
const encryptedBodyBytes = read(encryptedBodyLen);
const signatureBytes = read(64);
@@ -367,7 +306,6 @@ function parseSignedMessageBlockBytes(bytes) {
const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce });
const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
const bodyText = new TextDecoder().decode(encryptedBodyBytes);
const parsedBody = parseDmTextAttachments(bodyText);
return {
toLogin,
fromLogin,
@@ -377,11 +315,10 @@ function parseSignedMessageBlockBytes(bytes) {
revisionTimeMs,
formatVersionMajor,
formatVersionMinor,
attachments,
encryptedBodyBytes,
encryptedBodyText: bodyText,
text: parsedBody.text,
bodyAttachments: parsedBody.attachments,
text: bodyText,
bodyAttachments: [],
payloadBytes: encryptedBodyBytes,
signatureBytes,
signedBody,
@@ -418,7 +355,6 @@ function parseSignedMessageBlockBytes(bytes) {
nonce,
messageType,
revisionTimeMs: 0,
attachments: [],
encryptedBodyBytes: payloadBytes,
encryptedBodyText: new TextDecoder().decode(payloadBytes),
text: new TextDecoder().decode(payloadBytes),
@@ -1981,7 +1917,6 @@ export class AuthService {
nonce,
messageType,
revisionTimeMs = 0,
attachments = [],
encryptedBodyBytes = new Uint8Array(0),
}) {
const cleanFromLogin = String(login || '').trim();
@@ -1999,7 +1934,6 @@ export class AuthService {
const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin');
const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin');
const attachmentsSection = buildDmAttachmentsSectionBytes(attachments);
const preimage = concatBytes(
DM_PREFIX_V1,
uint8Bytes(DM_FORMAT_VERSION_MAJOR),
@@ -2010,7 +1944,7 @@ export class AuthService {
uint32Bytes(nonce),
uint16Bytes(messageType),
uint64Bytes(revisionTimeMs),
attachmentsSection,
uint8Bytes(0),
uint32Bytes(encryptedBodyBytes.length),
encryptedBodyBytes,
);
@@ -2073,25 +2007,13 @@ export class AuthService {
}
async sendDirectMessage({ login, toLogin, text, storagePwd }) {
return this.sendDirectMessageWithAttachments({ login, toLogin, text, storagePwd, attachments: [] });
}
async sendDirectMessageWithAttachments({
login,
toLogin,
text,
storagePwd,
attachments = [],
timeMs = Date.now(),
nonce = Math.floor(Math.random() * 0x100000000),
revisionTimeMs = 0,
}) {
const cleanFromLogin = String(login || '').trim();
const cleanToLogin = String(toLogin || '').trim();
const cleanText = String(text || '');
const normalizedAttachments = Array.isArray(attachments) ? attachments : [];
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
if (!cleanText && normalizedAttachments.length === 0) throw new Error('Пустое сообщение');
if (!cleanText) throw new Error('Пустое сообщение');
const timeMs = Date.now();
const nonce = Math.floor(Math.random() * 0x100000000);
const encryptedBodyBytes = utf8Bytes(cleanText);
const incomingBlock = await this.buildSignedDmV1Block({
@@ -2101,8 +2023,6 @@ export class AuthService {
timeMs,
nonce,
messageType: DM2_TYPE_INCOMING,
revisionTimeMs,
attachments: normalizedAttachments,
encryptedBodyBytes,
});
const outgoingBlock = await this.buildSignedDmV1Block({
@@ -2112,8 +2032,6 @@ export class AuthService {
timeMs,
nonce,
messageType: DM2_TYPE_OUTGOING_COPY,
revisionTimeMs,
attachments: normalizedAttachments,
encryptedBodyBytes,
});
@@ -2164,90 +2082,6 @@ export class AuthService {
return response.payload || {};
}
getHttpBaseUrl(serverHttpBase = '') {
const explicit = String(serverHttpBase || '').trim();
if (explicit) return explicit.replace(/\/$/, '');
try {
const parsed = new URL(this.serverUrl);
parsed.protocol = parsed.protocol === 'wss:' ? 'https:' : 'http:';
parsed.pathname = '';
parsed.search = '';
parsed.hash = '';
return parsed.toString().replace(/\/$/, '');
} catch {
return '';
}
}
buildDmFileUrl(encHashB64u, serverHttpBase = '') {
const base = this.getHttpBaseUrl(serverHttpBase);
return `${base}/f/${encodeURIComponent(String(encHashB64u || '').trim())}`;
}
async headDmFile(encHashB64u, serverHttpBase = '') {
const url = this.buildDmFileUrl(encHashB64u, serverHttpBase);
const response = await fetch(url, { method: 'HEAD' });
return response.status === 200;
}
async uploadDmFileCiphertext({ encHashB64u, encSize, ciphertextBytes, serverHttpBase = '' }) {
const base = this.getHttpBaseUrl(serverHttpBase);
const url = `${base}/upload?hash=${encodeURIComponent(String(encHashB64u || '').trim())}&size=${encodeURIComponent(String(encSize || 0))}`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
},
body: ciphertextBytes,
});
if (!response.ok) {
throw new Error(`upload_failed_${response.status}`);
}
return response.json();
}
async prepareEncryptedDmAttachment(file) {
if (!(file instanceof File)) throw new Error('Ожидался File');
const fileName = String(file.name || 'file.bin');
if (!fileName || /[|:>\n\r]/.test(fileName)) {
throw new Error('Имя файла содержит запрещённые символы для DM-протокола');
}
const plainBytes = new Uint8Array(await file.arrayBuffer());
const origHashBytes = await sha256Bytes(plainBytes);
const aesKeyBytes = randomBytes(32);
const ivBytes = randomBytes(12);
const cipherBytes = await encryptBytesAesGcm(plainBytes, aesKeyBytes, ivBytes);
const encHashBytes = await sha256Bytes(cipherBytes);
const type = String(file.type || '').startsWith('image/')
? 'photo'
: (String(file.type || '').startsWith('video/')
? 'video'
: (String(file.type || '').startsWith('audio/') ? 'audio' : 'file'));
return {
type,
mime: String(file.type || 'application/octet-stream'),
fileName,
origSize: plainBytes.length,
origHashB64u: bytesToBase64Url(origHashBytes),
encHashB64u: bytesToBase64Url(encHashBytes),
encSize: cipherBytes.length,
keyB64u: bytesToBase64Url(aesKeyBytes),
nonceB64u: bytesToBase64Url(ivBytes),
ciphertextBytes: cipherBytes,
};
}
async downloadAndDecryptDmAttachment(attachment, serverHttpBase = '') {
const encHashB64u = String(attachment?.encHashB64u || '').trim();
if (!encHashB64u) throw new Error('Не указан encHashB64u');
const response = await fetch(this.buildDmFileUrl(encHashB64u, serverHttpBase));
if (!response.ok) throw new Error(`download_failed_${response.status}`);
const cipherBytes = new Uint8Array(await response.arrayBuffer());
const keyBytes = base64UrlToBytes(String(attachment?.keyB64u || '').trim());
const nonceBytes = base64UrlToBytes(String(attachment?.nonceB64u || '').trim());
return decryptBytesAesGcm(cipherBytes, keyBytes, nonceBytes);
}
async callInviteBroadcast({ toLogin, callId, type = 100 }) {
const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type });