SHA256
НЕ ПРОВЕРЕНО: DM-вложения, upload файлов и ревизии личных сообщений
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
import { WsJsonClient } from './ws-client.js';
|
||||
import {
|
||||
base64ToBytes,
|
||||
base64UrlToBytes,
|
||||
bytesToBase64,
|
||||
bytesToBase64Url,
|
||||
decryptBytesAesGcm,
|
||||
deriveEd25519FromMasterSecret,
|
||||
deriveMasterSecretFromPassword,
|
||||
encryptBytesAesGcm,
|
||||
exportEd25519PublicKeyB64,
|
||||
exportPkcs8B64,
|
||||
generateEd25519Pair,
|
||||
importPkcs8Ed25519,
|
||||
publicKeyB64FromPkcs8Ed25519,
|
||||
randomBytes,
|
||||
randomBase64,
|
||||
sha256Bytes,
|
||||
signBytes,
|
||||
@@ -199,11 +204,16 @@ function uint8Bytes(value) {
|
||||
return new Uint8Array([Number(value) & 0xff]);
|
||||
}
|
||||
|
||||
const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM');
|
||||
const DM2_PREFIX = utf8Bytes('SHiNE_dm2');
|
||||
const DM2_TYPE_INCOMING = 1;
|
||||
const DM2_TYPE_OUTGOING_COPY = 2;
|
||||
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) {
|
||||
const text = String(value || '').trim();
|
||||
@@ -238,6 +248,51 @@ 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;
|
||||
@@ -274,6 +329,70 @@ function parseSignedMessageBlockBytes(bytes) {
|
||||
return text;
|
||||
};
|
||||
|
||||
const startsWith = (prefix) => {
|
||||
if (bytes.length < prefix.length) return false;
|
||||
for (let i = 0; i < prefix.length; i += 1) {
|
||||
if (bytes[i] !== prefix[i]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
if (startsWith(DM_PREFIX_V1)) {
|
||||
read(DM_PREFIX_V1.length);
|
||||
const formatVersionMajor = readU8();
|
||||
const formatVersionMinor = readU8();
|
||||
const toLogin = readAscii();
|
||||
const fromLogin = readAscii();
|
||||
const timeMs = readU64();
|
||||
const nonce = readU32();
|
||||
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,
|
||||
});
|
||||
}
|
||||
const encryptedBodyLen = readU32();
|
||||
const encryptedBodyBytes = read(encryptedBodyLen);
|
||||
const signatureBytes = read(64);
|
||||
if (o !== bytes.length) throw new Error('BAD_LEN');
|
||||
const signedBody = bytes.slice(0, bytes.length - 64);
|
||||
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,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType,
|
||||
revisionTimeMs,
|
||||
formatVersionMajor,
|
||||
formatVersionMinor,
|
||||
attachments,
|
||||
encryptedBodyBytes,
|
||||
encryptedBodyText: bodyText,
|
||||
text: parsedBody.text,
|
||||
bodyAttachments: parsedBody.attachments,
|
||||
payloadBytes: encryptedBodyBytes,
|
||||
signatureBytes,
|
||||
signedBody,
|
||||
rawBytes: bytes,
|
||||
baseKey,
|
||||
messageKey,
|
||||
legacyFormat: false,
|
||||
deleted: attachmentsCount === 0 && encryptedBodyLen === 0,
|
||||
};
|
||||
}
|
||||
|
||||
const prefix = read(DM2_PREFIX.length);
|
||||
for (let i = 0; i < DM2_PREFIX.length; i += 1) {
|
||||
if (prefix[i] !== DM2_PREFIX[i]) throw new Error('BAD_PREFIX');
|
||||
@@ -298,12 +417,20 @@ function parseSignedMessageBlockBytes(bytes) {
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType,
|
||||
revisionTimeMs: 0,
|
||||
attachments: [],
|
||||
encryptedBodyBytes: payloadBytes,
|
||||
encryptedBodyText: new TextDecoder().decode(payloadBytes),
|
||||
text: new TextDecoder().decode(payloadBytes),
|
||||
bodyAttachments: [],
|
||||
payloadBytes,
|
||||
signatureBytes,
|
||||
signedBody,
|
||||
rawBytes: bytes,
|
||||
baseKey,
|
||||
messageKey,
|
||||
legacyFormat: true,
|
||||
deleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1846,6 +1973,51 @@ export class AuthService {
|
||||
return concatBytes(preimage, signature);
|
||||
}
|
||||
|
||||
async buildSignedDmV1Block({
|
||||
login,
|
||||
toLogin,
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType,
|
||||
revisionTimeMs = 0,
|
||||
attachments = [],
|
||||
encryptedBodyBytes = new Uint8Array(0),
|
||||
}) {
|
||||
const cleanFromLogin = String(login || '').trim();
|
||||
const cleanToLogin = String(toLogin || '').trim();
|
||||
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
|
||||
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
|
||||
if (!(encryptedBodyBytes instanceof Uint8Array) || encryptedBodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) {
|
||||
throw new Error(`encryptedBody должен быть 0..${DM_MAX_ENCRYPTED_BODY_BYTES} байт`);
|
||||
}
|
||||
|
||||
const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
|
||||
const devicePriv = secrets?.deviceKey;
|
||||
if (!devicePriv) throw new Error('Не найден приватный deviceKey');
|
||||
const privateKey = await importPkcs8Ed25519(devicePriv);
|
||||
|
||||
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),
|
||||
uint8Bytes(DM_FORMAT_VERSION_MINOR),
|
||||
uint8Bytes(toBytes.length), toBytes,
|
||||
uint8Bytes(fromBytes.length), fromBytes,
|
||||
uint64Bytes(timeMs),
|
||||
uint32Bytes(nonce),
|
||||
uint16Bytes(messageType),
|
||||
uint64Bytes(revisionTimeMs),
|
||||
attachmentsSection,
|
||||
uint32Bytes(encryptedBodyBytes.length),
|
||||
encryptedBodyBytes,
|
||||
);
|
||||
const signature = await signBytes(privateKey, preimage);
|
||||
return concatBytes(preimage, signature);
|
||||
}
|
||||
|
||||
parseSignedMessageBlob(blobB64) {
|
||||
const bytes = base64ToBytes(String(blobB64 || '').trim());
|
||||
return parseSignedMessageBlockBytes(bytes);
|
||||
@@ -1901,33 +2073,48 @@ 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 || '');
|
||||
if (!cleanFromLogin || !cleanToLogin || !cleanText) throw new Error('Не передан login/toLogin/text');
|
||||
const normalizedAttachments = Array.isArray(attachments) ? attachments : [];
|
||||
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
|
||||
if (!cleanText && normalizedAttachments.length === 0) throw new Error('Пустое сообщение');
|
||||
const encryptedBodyBytes = utf8Bytes(cleanText);
|
||||
|
||||
const timeMs = Date.now();
|
||||
const nonce = Math.floor(Math.random() * 0x100000000);
|
||||
const incomingPayload = utf8Bytes(cleanText);
|
||||
const outgoingPayload = utf8Bytes(cleanText);
|
||||
|
||||
const incomingBlock = await this.buildSignedDm2Block({
|
||||
const incomingBlock = await this.buildSignedDmV1Block({
|
||||
login: cleanFromLogin,
|
||||
toLogin: cleanToLogin,
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType: DM2_TYPE_INCOMING,
|
||||
payloadBytes: incomingPayload,
|
||||
revisionTimeMs,
|
||||
attachments: normalizedAttachments,
|
||||
encryptedBodyBytes,
|
||||
});
|
||||
const outgoingBlock = await this.buildSignedDm2Block({
|
||||
const outgoingBlock = await this.buildSignedDmV1Block({
|
||||
login: cleanFromLogin,
|
||||
toLogin: cleanToLogin,
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType: DM2_TYPE_OUTGOING_COPY,
|
||||
payloadBytes: outgoingPayload,
|
||||
revisionTimeMs,
|
||||
attachments: normalizedAttachments,
|
||||
encryptedBodyBytes,
|
||||
});
|
||||
|
||||
const payload = await this.sendMessagePair({
|
||||
@@ -1977,6 +2164,90 @@ 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 });
|
||||
|
||||
@@ -25,6 +25,10 @@ function base64UrlToBase64(value) {
|
||||
return normalized + '='.repeat(padLen);
|
||||
}
|
||||
|
||||
function base64ToBase64Url(value) {
|
||||
return String(value || '').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
export function bytesToBase58(bytes) {
|
||||
@@ -93,6 +97,10 @@ export function bytesToBase64(bytes) {
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function bytesToBase64Url(bytes) {
|
||||
return base64ToBase64Url(bytesToBase64(bytes));
|
||||
}
|
||||
|
||||
export function base64ToBytes(base64) {
|
||||
const normalized = (base64 || '').trim();
|
||||
const binary = atob(normalized);
|
||||
@@ -103,6 +111,10 @@ export function base64ToBytes(base64) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function base64UrlToBytes(value) {
|
||||
return base64ToBytes(base64UrlToBase64(String(value || '').trim()));
|
||||
}
|
||||
|
||||
export function utf8Bytes(value) {
|
||||
return encoder.encode(value);
|
||||
}
|
||||
@@ -260,6 +272,28 @@ export async function decryptJsonWithStoragePwd(envelope, storagePwd) {
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
export async function importAesKeyRaw(keyBytes, usages = ['encrypt', 'decrypt']) {
|
||||
return getSubtleApi().importKey('raw', keyBytes, { name: 'AES-GCM' }, false, usages);
|
||||
}
|
||||
|
||||
export async function encryptBytesAesGcm(plainBytes, keyBytes, ivBytes) {
|
||||
const key = await importAesKeyRaw(keyBytes, ['encrypt']);
|
||||
const cipher = await getSubtleApi().encrypt({ name: 'AES-GCM', iv: ivBytes }, key, plainBytes);
|
||||
return new Uint8Array(cipher);
|
||||
}
|
||||
|
||||
export async function decryptBytesAesGcm(cipherBytes, keyBytes, ivBytes) {
|
||||
const key = await importAesKeyRaw(keyBytes, ['decrypt']);
|
||||
const plain = await getSubtleApi().decrypt({ name: 'AES-GCM', iv: ivBytes }, key, cipherBytes);
|
||||
return new Uint8Array(plain);
|
||||
}
|
||||
|
||||
export function randomBytes(byteLen = 32) {
|
||||
const out = new Uint8Array(byteLen);
|
||||
getCryptoApi().getRandomValues(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function generateEd25519Pair() {
|
||||
return getSubtleApi().generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,13 @@ export async function putStoredMessage(record) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteStoredMessage(messageKey) {
|
||||
if (!messageKey) return;
|
||||
await withStore('readwrite', (store) => {
|
||||
store.delete(messageKey);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listStoredMessages() {
|
||||
return withStore('readonly', (store) => new Promise((resolve, reject) => {
|
||||
const req = store.getAll();
|
||||
|
||||
Reference in New Issue
Block a user