НЕ ПРОВЕРЕНО: DM-вложения, upload файлов и ревизии личных сообщений

This commit is contained in:
AidarKC
2026-06-18 11:46:58 +04:00
parent 2225c2d173
commit 92fd315505
25 changed files with 1910 additions and 516 deletions
+6 -3
View File
@@ -900,7 +900,7 @@ async function init() {
const messageType = Number(parsed.messageType || 0);
const chatId = messageType === 2 ? toLogin : fromLogin;
const text = (messageType === 1 || messageType === 2)
? new TextDecoder().decode(parsed.payloadBytes || new Uint8Array(0))
? String(parsed.text || '')
: '';
let shouldRefreshToolbarUnread = false;
@@ -916,15 +916,18 @@ async function init() {
messageType,
unread: isIncomingForCurrent,
rawBlobB64: blobB64,
revisionTimeMs: Number(parsed.revisionTimeMs || 0),
attachments: Array.isArray(parsed.bodyAttachments) ? parsed.bodyAttachments : [],
deleted: Boolean(parsed.deleted),
});
if (added) {
addAppLogEntry({
level: 'info',
source: 'signed-dm',
message: isIncomingForCurrent
? `Новое входящее сообщение от ${fromLogin}`
? `Обновлено входящее сообщение от ${fromLogin}`
: `Синхронизирована исходящая копия в чате ${chatId}`,
details: { messageKey, baseKey: parsed.baseKey, messageType },
details: { messageKey, baseKey: parsed.baseKey, messageType, revisionTimeMs: parsed.revisionTimeMs || 0, deleted: !!parsed.deleted },
});
}
if (added && isIncomingForCurrent) {
+154 -11
View File
@@ -3,6 +3,7 @@ import { directMessages } from '../mock-data.js';
import {
addAppLogEntry,
addChatMessage,
addSignedMessageToChat,
addSystemChatMessage,
addOutgoingPendingMessage,
getChatMessages,
@@ -165,6 +166,35 @@ function resolveDeliveryStatus(msg) {
return '…';
}
function formatFileSize(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value < 1024) return `${Math.max(0, Math.trunc(value))} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
function messagePlainText(msg) {
return String(msg?.text || '').trim();
}
async function downloadAttachment(attachment) {
const fileName = String(attachment?.fileName || 'file.bin');
const mime = String(attachment?.mime || 'application/octet-stream');
const plainBytes = await authService.downloadAndDecryptDmAttachment(attachment, state.entrySettings.shineServerHttp);
const blob = new Blob([plainBytes], { type: mime });
const url = URL.createObjectURL(blob);
try {
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.append(link);
link.click();
link.remove();
} finally {
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
}
function scrollToLatestMessage(list) {
if (!list) return;
const apply = () => {
@@ -198,9 +228,35 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
const bubbleKind = String(msg?.kind || '').trim();
bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`;
const textNode = document.createElement('div');
textNode.className = 'bubble-text';
textNode.textContent = msg.text || '';
const plainText = messagePlainText(msg);
if (plainText) {
const textNode = document.createElement('div');
textNode.className = 'bubble-text';
textNode.textContent = plainText;
bubble.append(textNode);
}
const attachments = Array.isArray(msg?.attachments) ? msg.attachments : [];
if (attachments.length) {
const attachmentsNode = document.createElement('div');
attachmentsNode.className = 'stack';
attachments.forEach((attachment) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'secondary-btn';
btn.textContent = `${attachment?.fileName || 'file'}${formatFileSize(attachment?.origSize || attachment?.encSize || 0)}`;
btn.addEventListener('click', async (event) => {
event.stopPropagation();
try {
await downloadAttachment(attachment);
} catch (error) {
showToast(`Не удалось скачать файл: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
});
attachmentsNode.append(btn);
});
bubble.append(attachmentsNode);
}
const metaNode = document.createElement('div');
metaNode.className = 'bubble-meta';
@@ -218,7 +274,7 @@ function renderLog(list, chatId, { onOpenActions } = {}) {
metaNode.append(statusNode);
}
bubble.append(textNode, metaNode);
bubble.append(metaNode);
bubble.addEventListener('click', () => {
if (typeof onOpenActions === 'function') onOpenActions(msg);
});
@@ -332,17 +388,64 @@ export function render({ navigate, route }) {
const form = document.createElement('form');
form.className = 'chat-input dm-chat-input';
form.innerHTML = `
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="2000"></textarea>
<input type="file" id="chat-file-input" multiple hidden />
<div class="stack" id="chat-attachments-preview"></div>
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea>
<div class="dm-actions-col">
<button class="ghost-btn dm-voice-btn" type="button" id="chat-file-pick" title="Вложить файлы">📎</button>
<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button>
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить">➤</button>
</div>
`;
const fileInput = form.querySelector('#chat-file-input');
const attachmentsPreview = form.querySelector('#chat-attachments-preview');
let pendingFiles = [];
const renderPendingFiles = () => {
if (!attachmentsPreview) return;
attachmentsPreview.innerHTML = '';
pendingFiles.forEach((file, index) => {
const row = document.createElement('div');
row.className = 'meta-muted';
row.textContent = `${file.name}${formatFileSize(file.size)}`;
row.addEventListener('click', () => {
pendingFiles = pendingFiles.filter((_, current) => current !== index);
renderPendingFiles();
});
attachmentsPreview.append(row);
});
};
const buildMessagePayloadText = (plainText, preparedAttachments) => {
const parts = [];
const text = String(plainText || '').trim();
if (text) parts.push(text);
preparedAttachments.forEach((item) => {
parts.push(`<<file:file-format(1.0):${item.type}|${item.fileName}|${item.origSize}|${item.origHashB64u}|${item.encHashB64u}|${item.encSize}|${item.keyB64u}|${item.nonceB64u}>>`);
});
return parts.join('\n');
};
const ensureUploads = async (preparedAttachments) => {
for (const item of preparedAttachments) {
const exists = await authService.headDmFile(item.encHashB64u, state.entrySettings.shineServerHttp);
if (!exists) {
await authService.uploadDmFileCiphertext({
encHashB64u: item.encHashB64u,
encSize: item.encSize,
ciphertextBytes: item.ciphertextBytes,
serverHttpBase: state.entrySettings.shineServerHttp,
});
}
}
};
const sendTextMessage = async (rawText) => {
const text = String(rawText || '').trim();
if (!text) return;
const tempId = addOutgoingPendingMessage(chatId, text);
if (!text && pendingFiles.length === 0) return;
const tempLabel = text || `Файлы: ${pendingFiles.length}`;
const tempId = addOutgoingPendingMessage(chatId, tempLabel);
renderLog(log, chatId, {
onOpenActions: (msg) => openMessageActionsModal({
messageText: msg?.text || '',
@@ -357,16 +460,47 @@ export function render({ navigate, route }) {
});
try {
const result = await authService.sendDirectMessage({
const filesToSend = pendingFiles.slice(0, 12);
const preparedAttachments = [];
for (const file of filesToSend) {
preparedAttachments.push(await authService.prepareEncryptedDmAttachment(file));
}
await ensureUploads(preparedAttachments);
const messagePayloadText = buildMessagePayloadText(text, preparedAttachments);
const result = await authService.sendDirectMessageWithAttachments({
login: state.session.login,
toLogin: chatId,
text,
text: messagePayloadText,
storagePwd: state.session.storagePwdInMemory,
attachments: preparedAttachments,
});
pendingFiles = [];
if (fileInput) fileInput.value = '';
renderPendingFiles();
markOutgoingSent(tempId, {
messageKey: result?.outgoingKey || '',
baseKey: result?.baseKey || result?.localBaseKey || '',
});
if (result?.localOutgoingBlobB64) {
try {
const parsed = authService.parseSignedMessageBlob(result.localOutgoingBlobB64);
addSignedMessageToChat({
chatId,
messageKey: result?.outgoingKey || parsed?.messageKey || '',
baseKey: result?.baseKey || parsed?.baseKey || '',
from: 'out',
text: parsed?.text || '',
messageType: Number(parsed?.messageType || 2),
unread: false,
rawBlobB64: result.localOutgoingBlobB64,
revisionTimeMs: Number(parsed?.revisionTimeMs || 0),
attachments: Array.isArray(parsed?.bodyAttachments) ? parsed.bodyAttachments : [],
deleted: Boolean(parsed?.deleted),
});
} catch {
// ignore local parse failure; server backlog/realtime will reconcile later
}
}
renderLog(log, chatId, {
onOpenActions: (msg) => openMessageActionsModal({
messageText: msg?.text || '',
@@ -417,6 +551,15 @@ export function render({ navigate, route }) {
};
const input = form.elements.message;
form.querySelector('#chat-file-pick')?.addEventListener('click', () => fileInput?.click());
fileInput?.addEventListener('change', () => {
const selected = Array.from(fileInput.files || []);
if (selected.length > 12) {
showToast('Можно приложить не больше 12 файлов', { kind: 'error', timeoutMs: 1400 });
}
pendingFiles = selected.slice(0, 12);
renderPendingFiles();
});
autoResizeComposer(input);
input?.addEventListener('input', () => autoResizeComposer(input));
input?.addEventListener('focus', () => {
@@ -441,7 +584,7 @@ export function render({ navigate, route }) {
}
event.preventDefault();
const text = String(input.value || '').trim();
if (!text) return;
if (!text && pendingFiles.length === 0) return;
input.value = '';
autoResizeComposer(input);
await sendTextMessage(text);
@@ -465,7 +608,7 @@ export function render({ navigate, route }) {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const text = input.value.trim();
if (!text) return;
if (!text && pendingFiles.length === 0) return;
input.value = '';
autoResizeComposer(input);
await sendTextMessage(text);
+281 -10
View File
@@ -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 });
+34
View File
@@ -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']);
}
+7
View File
@@ -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();
+45 -16
View File
@@ -1,5 +1,5 @@
import { AuthService } from './services/auth-service.js';
import { listStoredMessages, putStoredMessage, clearStoredMessages } from './services/message-store.js';
import { listStoredMessages, putStoredMessage, deleteStoredMessage, clearStoredMessages } from './services/message-store.js';
import { SOLANA_ENDPOINT_DEFAULT } from './solana-programs.js';
import {
DEFAULT_SHINE_SERVER_HTTP,
@@ -378,15 +378,22 @@ function persistMessageRecord(chatId, row) {
baseKey: String(row.baseKey || ''),
messageType: Number(row.messageType || 0),
rawBlobB64: String(row.rawBlobB64 || ''),
revisionTimeMs: Number(row.revisionTimeMs || 0),
unread: Boolean(row.unread),
firstTick: Boolean(row.firstTick),
secondTick: Boolean(row.secondTick),
readReceiptSent: Boolean(row.readReceiptSent),
refBaseKey: String(row.refBaseKey || ''),
attachments: Array.isArray(row.attachments) ? row.attachments : [],
ts: resolvedTs > 0 ? resolvedTs : Date.now(),
}).catch(() => {});
}
function removeStoredMessageRecord(messageKey) {
if (!messageKey) return;
void deleteStoredMessage(messageKey).catch(() => {});
}
export async function hydrateMessagesFromStore() {
try {
const rows = await listStoredMessages();
@@ -407,11 +414,13 @@ export async function hydrateMessagesFromStore() {
baseKey: String(row.baseKey || ''),
messageType: Number(row.messageType || 0),
rawBlobB64: String(row.rawBlobB64 || ''),
revisionTimeMs: Number(row.revisionTimeMs || 0),
unread: Boolean(row.unread),
firstTick: Boolean(row.firstTick),
secondTick: Boolean(row.secondTick),
readReceiptSent: Boolean(row.readReceiptSent),
refBaseKey: String(row.refBaseKey || ''),
attachments: Array.isArray(row.attachments) ? row.attachments : [],
createdAtMs: Number(row.ts || 0),
});
});
@@ -565,25 +574,45 @@ export function addSignedMessageToChat({
unread = false,
rawBlobB64 = '',
refBaseKey = '',
revisionTimeMs = 0,
attachments = [],
deleted = false,
} = {}) {
const id = String(messageKey || '').trim();
if (!chatId || !id) return false;
if (state.knownMessageKeys[id]) return false;
state.knownMessageKeys[id] = true;
const list = getChatMessages(chatId);
const existingIndex = list.findIndex((row) => String(row?.messageKey || '').trim() === id);
const existing = existingIndex >= 0 ? list[existingIndex] : null;
const row = {
from: from === 'out' ? 'out' : 'in',
text: String(text || ''),
messageKey: id,
baseKey: String(baseKey || ''),
messageType: Number(messageType || 0),
rawBlobB64: String(rawBlobB64 || ''),
unread: Boolean(unread),
refBaseKey: String(refBaseKey || ''),
firstTick: from === 'out',
secondTick: false,
};
getChatMessages(chatId).push(row);
if (deleted) {
if (existingIndex >= 0) {
list.splice(existingIndex, 1);
removeStoredMessageRecord(id);
sortChatMessagesInPlace(chatId);
return true;
}
return false;
}
state.knownMessageKeys[id] = true;
const row = existing || {};
row.from = from === 'out' ? 'out' : 'in';
row.text = String(text || '');
row.messageKey = id;
row.baseKey = String(baseKey || '');
row.messageType = Number(messageType || 0);
row.rawBlobB64 = String(rawBlobB64 || '');
row.revisionTimeMs = Number(revisionTimeMs || 0);
row.attachments = Array.isArray(attachments) ? attachments : [];
row.unread = row.from === 'in' ? Boolean(unread) : false;
row.refBaseKey = String(refBaseKey || '');
row.firstTick = row.from === 'out';
row.secondTick = Boolean(existing?.secondTick);
row.readReceiptSent = Boolean(existing?.readReceiptSent);
if (existingIndex < 0) {
list.push(row);
}
sortChatMessagesInPlace(chatId);
persistMessageRecord(chatId, row);
return true;