SHA256
205 lines
7.4 KiB
JavaScript
205 lines
7.4 KiB
JavaScript
import {
|
|
base64UrlToBytes,
|
|
bytesToBase58,
|
|
bytesToBase64Url,
|
|
decryptBytesAesGcm,
|
|
encryptBytesAesGcm,
|
|
importPkcs8Ed25519,
|
|
randomBytes,
|
|
sha256Bytes,
|
|
signBase64,
|
|
} from './crypto-utils.js';
|
|
import { loadSessionMaterial } from './key-vault.js';
|
|
|
|
export const DM_FILE_MAX_SOURCE_BYTES = 50 * 1024 * 1024;
|
|
|
|
function normalizeFileName(value = '') {
|
|
const cleaned = String(value || 'file')
|
|
.replace(/[\\/\u0000-\u001f\u007f]/g, '_')
|
|
.trim();
|
|
return (cleaned || 'file').slice(0, 180);
|
|
}
|
|
|
|
function wsUrlToHttpBase(wsUrl = '') {
|
|
const parsed = new URL(String(wsUrl || ''), window.location.href);
|
|
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
|
|
else if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
throw new Error('Не удалось определить HTTP-адрес сервера SHiNE');
|
|
}
|
|
parsed.pathname = '/';
|
|
parsed.search = '';
|
|
parsed.hash = '';
|
|
return parsed.origin;
|
|
}
|
|
|
|
function uploadPreimage({ sessionId, fileId, encryptedSize, timeMs }) {
|
|
return `DM_FILE_UPLOAD_V1:${sessionId}:${fileId}:${Number(encryptedSize)}:${Number(timeMs)}`;
|
|
}
|
|
|
|
async function readErrorMessage(response) {
|
|
try {
|
|
const raw = String(await response.text()).trim();
|
|
if (!raw) return '';
|
|
try {
|
|
const payload = JSON.parse(raw);
|
|
return String(payload?.message || payload?.error || raw).trim();
|
|
} catch {
|
|
return raw;
|
|
}
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
export function formatDmFileSize(bytes = 0) {
|
|
const value = Math.max(0, Number(bytes || 0));
|
|
if (value < 1024) return `${value} Б`;
|
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} КБ`;
|
|
return `${(value / (1024 * 1024)).toFixed(value >= 10 * 1024 * 1024 ? 0 : 1)} МБ`;
|
|
}
|
|
|
|
export async function encryptAndUploadDmFile({ file, login, sessionId, wsUrl } = {}) {
|
|
if (!file || typeof file.arrayBuffer !== 'function') throw new Error('Файл не выбран');
|
|
const cleanLogin = String(login || '').trim();
|
|
const cleanSessionId = String(sessionId || '').trim();
|
|
if (!cleanLogin || !cleanSessionId) throw new Error('Нет активной пользовательской сессии');
|
|
if (Number(file.size || 0) > DM_FILE_MAX_SOURCE_BYTES) {
|
|
throw new Error(`Файл слишком большой. Текущий лимит — ${formatDmFileSize(DM_FILE_MAX_SOURCE_BYTES)}.`);
|
|
}
|
|
|
|
const sessionMaterial = await loadSessionMaterial(cleanLogin);
|
|
if (!sessionMaterial?.sessionPrivPkcs8) {
|
|
throw new Error('На устройстве нет сохранённого session key для загрузки файла');
|
|
}
|
|
if (sessionMaterial.sessionId && String(sessionMaterial.sessionId) !== cleanSessionId) {
|
|
throw new Error('Сохранённый session key относится к другой сессии');
|
|
}
|
|
|
|
const sourceBytes = new Uint8Array(await file.arrayBuffer());
|
|
const fileKey = randomBytes(32);
|
|
const iv = randomBytes(12);
|
|
let encryptedBytes;
|
|
try {
|
|
encryptedBytes = await encryptBytesAesGcm(sourceBytes, fileKey, iv);
|
|
} finally {
|
|
sourceBytes.fill(0);
|
|
}
|
|
|
|
try {
|
|
const fileHash = await sha256Bytes(encryptedBytes);
|
|
const fileId = bytesToBase58(fileHash);
|
|
const serverBase = wsUrlToHttpBase(wsUrl);
|
|
const fileUrl = `${serverBase}/dm-files/${fileId}`;
|
|
const timeMs = Date.now();
|
|
const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8);
|
|
const signatureB64 = await signBase64(privateKey, uploadPreimage({
|
|
sessionId: cleanSessionId,
|
|
fileId,
|
|
encryptedSize: encryptedBytes.byteLength,
|
|
timeMs,
|
|
}));
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(fileUrl, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/octet-stream',
|
|
'X-Shine-Session-Id': cleanSessionId,
|
|
'X-Shine-Time-Ms': String(timeMs),
|
|
'X-Shine-Content-Length': String(encryptedBytes.byteLength),
|
|
'X-Shine-Signature': signatureB64,
|
|
},
|
|
body: encryptedBytes,
|
|
cache: 'no-store',
|
|
});
|
|
} catch (error) {
|
|
throw new Error(`Не удалось загрузить файл на сервер отправителя: ${error?.message || 'network error'}`);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const detail = await readErrorMessage(response);
|
|
throw new Error(detail || `Сервер отклонил файл (HTTP ${response.status})`);
|
|
}
|
|
|
|
return {
|
|
version: 1,
|
|
id: fileId,
|
|
url: fileUrl,
|
|
keyB64Url: bytesToBase64Url(fileKey),
|
|
ivB64Url: bytesToBase64Url(iv),
|
|
name: normalizeFileName(file.name),
|
|
mime: String(file.type || 'application/octet-stream').trim().slice(0, 160) || 'application/octet-stream',
|
|
size: Number(file.size || 0),
|
|
encryptedSize: encryptedBytes.byteLength,
|
|
};
|
|
} finally {
|
|
fileKey.fill(0);
|
|
iv.fill(0);
|
|
encryptedBytes?.fill(0);
|
|
}
|
|
}
|
|
|
|
export async function downloadAndDecryptDmFile(attachment = {}) {
|
|
const fileId = String(attachment?.id || '').trim();
|
|
const fileUrl = String(attachment?.url || '').trim();
|
|
const keyB64Url = String(attachment?.keyB64Url || '').trim();
|
|
const ivB64Url = String(attachment?.ivB64Url || '').trim();
|
|
if (!fileId || !fileUrl || !keyB64Url || !ivB64Url) {
|
|
throw new Error('В сообщении не хватает данных для расшифровки файла');
|
|
}
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(fileUrl, { method: 'GET', cache: 'no-store' });
|
|
} catch (error) {
|
|
throw new Error(`Не удалось скачать зашифрованный файл: ${error?.message || 'network error'}`);
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(response.status === 404 ? 'Файл больше не найден на сервере отправителя' : `Ошибка скачивания файла (HTTP ${response.status})`);
|
|
}
|
|
|
|
const encryptedBytes = new Uint8Array(await response.arrayBuffer());
|
|
const actualId = bytesToBase58(await sha256Bytes(encryptedBytes));
|
|
if (actualId !== fileId) {
|
|
encryptedBytes.fill(0);
|
|
throw new Error('SHA-256 файла не совпал: зашифрованный файл повреждён или подменён');
|
|
}
|
|
|
|
const keyBytes = base64UrlToBytes(keyB64Url);
|
|
const ivBytes = base64UrlToBytes(ivB64Url);
|
|
let plainBytes;
|
|
try {
|
|
plainBytes = await decryptBytesAesGcm(encryptedBytes, keyBytes, ivBytes);
|
|
} catch {
|
|
throw new Error('Не удалось расшифровать файл: ключ или содержимое повреждены');
|
|
} finally {
|
|
encryptedBytes.fill(0);
|
|
keyBytes.fill(0);
|
|
ivBytes.fill(0);
|
|
}
|
|
|
|
const expectedSize = Number(attachment?.size || 0);
|
|
if (expectedSize >= 0 && plainBytes.byteLength !== expectedSize) {
|
|
plainBytes.fill(0);
|
|
throw new Error('Размер расшифрованного файла не совпал с сообщением');
|
|
}
|
|
|
|
const blob = new Blob([plainBytes], {
|
|
type: String(attachment?.mime || 'application/octet-stream') || 'application/octet-stream',
|
|
});
|
|
plainBytes.fill(0);
|
|
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
const anchor = document.createElement('a');
|
|
anchor.href = objectUrl;
|
|
anchor.download = normalizeFileName(attachment?.name || 'file');
|
|
anchor.rel = 'noopener';
|
|
anchor.style.display = 'none';
|
|
document.body.append(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
|
}
|