SHA256
Первая версия вложенных файлов работает
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user