Сделать DM body opaque для сервера и улучшить fallback UI

This commit is contained in:
AidarKC
2026-07-10 18:12:17 +04:00
parent cc1f78106d
commit 076b43e542
10 changed files with 190 additions and 56 deletions
@@ -0,0 +1,17 @@
# Итог звонка как DM от инициатора
- краткое описание фичи:
Вместо локальных `call-tech` записей итог звонка теперь отправляется как обычное DM-сообщение только от стороны, которая инициировала звонок.
- что именно проверять:
1. Успешный звонок: после завершения инициатор отправляет в чат DM вида `Звонок: 37с` или `Звонок: 2м 14с`.
2. Неуспешный звонок без ответа: инициатор отправляет `Звонил, но недозвонился: нет ответа`.
3. Неуспешный звонок, когда у адресата нет доставленных сессий: инициатор отправляет `Звонил, но недозвонился: абонент не в сети`.
4. Неуспешный звонок при проблеме соединения: инициатор отправляет `Звонил, но недозвонился: не удалось установить соединение`.
5. Старые локальные `call-tech` bubble про итог звонка больше не появляются ни у инициатора, ни у принимающей стороны.
- ожидаемый результат:
Итог звонка виден обеим сторонам как обычное DM-сообщение от инициатора, а локальные служебные записи про итог звонка больше не используются.
- статус:
pending
@@ -0,0 +1,23 @@
## Краткое описание
Сервер перестал валидировать внутреннюю crypto-структуру `body` у контентных DM `type=1/2`.
UI теперь не отбрасывает такие сообщения: если сообщение не удалось расшифровать, вместо текста показывается заглушка `Неудалось расшифровать сообщение`.
## Что проверять
- Отправка и приём обычных DM между штатными клиентами продолжают работать как раньше.
- Сообщение с незнакомым форматом `body` у `type=1/2` принимается сервером и доходит до клиента.
- В UI такое сообщение отображается в чате одной из ожидаемых заглушек:
- `Формат сообщения не поддерживается`
- `Не удалось расшифровать сообщение`
- `Сообщение повреждено`
- После выхода из аккаунта и повторного входа backlog с такими сообщениями тоже отображается с той же заглушкой.
- ACK доставки по сессии для такого сообщения не зацикливается и сообщение не приходит бесконечно повторно.
## Ожидаемый результат
Сервер выступает транспортом для opaque DM-body, а клиент при неудачной расшифровке показывает безопасный fallback вместо полного пропуска сообщения с различением основных причин.
## Статус
pending
@@ -71,7 +71,28 @@
- сервер не должен расшифровывать DM;
- ciphertext у `type=1` и `type=2` обычно разный и не обязан совпадать побайтно.
### 2.3. Источник истины по пользователю
### 2.3. Что именно знает сервер о `body`
Для контентных DM (`type=1/2`) сервер в v1 должен рассматривать `body` как opaque blob:
- сервер проверяет внешний контейнер `SHiNE_DM`;
- сервер проверяет подпись;
- сервер проверяет служебные поля envelope;
- сервер проверяет только то, что `bodyLen > 0` и не превышает допустимый лимит;
- сервер не обязан разбирать внутренний crypto-контейнер `body`;
- сервер не обязан знать конкретный алгоритм шифрования `body`.
Это позволяет альтернативным клиентам использовать совместимый внешний DM-envelope при собственном формате зашифрованного payload внутри `body`.
Официальный UI SHiNE при приёме такого сообщения:
- пытается расшифровать знакомый формат `EncryptedBody_v1_0`;
- показывает текст сообщения при успешной расшифровке;
- если внутренний формат не поддерживается, показывает `Формат сообщения не поддерживается`;
- если формат понятен, но расшифровка не удалась, показывает `Не удалось расшифровать сообщение`;
- если `body` повреждён или структурно битый, показывает `Сообщение повреждено`.
### 2.4. Источник истины по пользователю
Для DM-проверки сервер использует:
@@ -534,6 +555,7 @@ UI-следствие для клиента:
- сервер не должен требовать одинаковый `encryptedBody` у `type=1` и `type=2`;
- сервер не должен трактовать `encryptedBody` как обычный UTF-8 текст;
- сервер не должен валидировать внутреннюю crypto-структуру `body` у контентных DM `type=1/2`;
- DM должны реально шифроваться end-to-end;
- удаление одного сообщения должно стать терминальным tombstone;
- удаление всей переписки должно стать отдельным служебным событием;
@@ -164,6 +164,12 @@ Nonce/IV для `AES-GCM`.
Сервер не должен трактовать это поле как UTF-8 текст и не должен пытаться расшифровывать его в обычной DM-логике.
Важно:
- официальный UI SHiNE сейчас использует именно `EncryptedBody_v1_0`;
- сервер для контентных DM `type=1/2` не должен требовать, чтобы `body` обязательно был распознан как `EncryptedBody_v1_0`;
- сервер должен хранить и пересылать `body` как opaque bytes, если внешний контейнер `SHiNE_DM` валиден.
### Точная схема для `cryptoMethod = 1`, `cryptoVersion = 0`
- публичный ключ получателя для E2EE получается как стандартное преобразование `Ed25519 -> X25519`;
@@ -115,7 +115,6 @@ final class SignedMessageBlock {
if (messageType == TYPE_INCOMING_TEXT || messageType == TYPE_OUTGOING_COPY) {
if (bodyBytes.length == 0) throw new IllegalArgumentException("BAD_MESSAGE_LEN");
encryptedBody = EncryptedBodyPayload.parse(bodyBytes, maxBodyBytes);
} else if (messageType == TYPE_READ_INCOMING || messageType == TYPE_READ_OUTGOING_COPY) {
if (bodyBytes.length == 0) throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN");
if (revisionTimeMs != 0 || reencryptedAtMs != 0) {
@@ -44,7 +44,7 @@ webpush.vapid.subject=mailto:admin@shine.local
# Тогда сервер будет выдавать временный username/password (TTL).
# ------------------------------------------------------------
call.ice.stun.urls=stun:stun.l.google.com:19302
call.ice.turn.urls=turn:37.214.58.208:3478?transport=udp,turn:37.214.58.208:3478?transport=tcp
call.ice.turn.urls=turn:185.229.109.118:3478?transport=udp,turn:185.229.109.118:3478?transport=tcp
call.ice.turn.ttlSec=600
call.ice.turn.userPrefix=shine
call.ice.turn.sharedSecret=
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.2.306
server.version=1.2.285
client.version=1.2.307
server.version=1.2.286
+17 -3
View File
@@ -83,6 +83,16 @@ import * as networkView from './pages/network-view.js';
import * as notificationsView from './pages/notifications-view.js';
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
const DM_DECRYPT_FAILED_TEXT = 'Не удалось расшифровать сообщение';
const DM_CORRUPTED_TEXT = 'Сообщение повреждено';
function dmDecryptFallbackText(error) {
const kind = authService.classifyDmDecryptFailure(error);
if (kind === 'unsupported_format') return DM_UNSUPPORTED_FORMAT_TEXT;
if (kind === 'corrupted') return DM_CORRUPTED_TEXT;
return DM_DECRYPT_FAILED_TEXT;
}
const routes = {
'start-view': startView,
@@ -1095,14 +1105,18 @@ async function init() {
});
text = String(decrypted?.text || '');
} catch (error) {
shouldAckSessionDelivery = false;
text = dmDecryptFallbackText(error);
addAppLogEntry({
level: 'warn',
source: 'signed-dm',
message: 'Не удалось расшифровать DM',
details: { messageKey, baseKey: parsed.baseKey, error: error?.message || 'unknown' },
details: {
messageKey,
baseKey: parsed.baseKey,
error: error?.message || 'unknown',
fallbackText: text,
},
});
return;
}
}
const chatId = resolveSignedDmChatId({
+15 -1
View File
@@ -239,6 +239,9 @@ const DM_MAX_ENCRYPTED_BODY_BYTES = 16384;
const DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM = 1;
const DM_CRYPTO_VERSION_1_0 = 0;
const DM_HKDF_INFO = utf8Bytes('SHiNE_DM|1|0|X25519+HKDF-SHA256+AES-256-GCM');
const DM_DECRYPT_ERROR_UNSUPPORTED_FORMAT = 'unsupported_format';
const DM_DECRYPT_ERROR_DECRYPT_FAILED = 'decrypt_failed';
const DM_DECRYPT_ERROR_CORRUPTED = 'corrupted';
function ensureAsciiBytes(value, field, min = 1, max = 60) {
const text = String(value || '').trim();
@@ -330,6 +333,14 @@ function parseEncryptedDmBodyBytes(payloadBytes) {
};
}
function classifyDmDecryptFailure(error) {
const code = String(error?.message || '').trim();
if (code === 'BAD_ENCRYPTED_BODY') return DM_DECRYPT_ERROR_CORRUPTED;
if (code === 'Неподдерживаемый метод шифрования DM') return DM_DECRYPT_ERROR_UNSUPPORTED_FORMAT;
if (code === 'OperationError') return DM_DECRYPT_ERROR_DECRYPT_FAILED;
return DM_DECRYPT_ERROR_DECRYPT_FAILED;
}
function parseReadReceiptBodyBytes(payloadBytes) {
if (!(payloadBytes instanceof Uint8Array)) throw new Error('BAD_RECEIPT_PAYLOAD_LEN');
let o = 0;
@@ -435,7 +446,6 @@ function parseSignedMessageBlockBytes(bytes) {
let readReceiptPayload = null;
if (messageType === DM_TYPE_INCOMING || messageType === DM_TYPE_OUTGOING_COPY) {
if (bodyBytes.length === 0) throw new Error('BAD_MESSAGE_LEN');
encryptedBodyPayload = parseEncryptedDmBodyBytes(bodyBytes);
} else if (messageType === DM_TYPE_READ_INCOMING || messageType === DM_TYPE_READ_OUTGOING_COPY) {
if (bodyBytes.length === 0) throw new Error('BAD_RECEIPT_PAYLOAD_LEN');
if (revisionTimeMs !== 0 || reencryptedAtMs !== 0) throw new Error('BAD_RECEIPT_REVISION');
@@ -2334,6 +2344,10 @@ export class AuthService {
return parseReadReceiptBodyBytes(payloadBytes);
}
classifyDmDecryptFailure(error) {
return classifyDmDecryptFailure(error);
}
async decryptSignedMessageContent({ parsed, blobB64 = '', login, storagePwd }) {
const parsedBlock = parsed || this.parseSignedMessageBlob(blobB64);
const messageType = Number(parsedBlock?.messageType || 0);
+86 -47
View File
@@ -1,4 +1,4 @@
import { addSystemChatMessage, authService, authorizeSession, state } from '../state.js';
import { addSignedMessageToChat, authService, authorizeSession, state } from '../state.js';
const TYPES = {
INVITE: 100,
@@ -100,6 +100,88 @@ function formatDuration(ms) {
return `${sec}с`;
}
function isInviteUndelivered(call) {
const wsDelivered = Number(call?.inviteDelivery?.deliveredWsSessions || 0);
const pushDelivered = Number(
call?.inviteDelivery?.deliveredWebPushSessions
|| call?.inviteDelivery?.deliveredFcmSessions
|| 0
);
return (wsDelivered + pushDelivered) <= 0;
}
function buildOutgoingCallSummaryText(call, summaryCode) {
if (!call || call.direction !== 'out') return '';
if (summaryCode === 'completed') {
const duration = formatDuration(nowMs() - Number(call.connectedAtMs || call.startedAtMs || nowMs()));
return `Звонок: ${duration}`;
}
if (summaryCode === 'busy') {
return 'Звонил, но недозвонился: абонент занят';
}
if (summaryCode === 'declined') {
return 'Звонил, но недозвонился: звонок отклонён';
}
if (summaryCode === 'no_answer') {
if (isInviteUndelivered(call)) {
return 'Звонил, но недозвонился: абонент не в сети';
}
return 'Звонил, но недозвонился: нет ответа';
}
if (summaryCode === 'error') {
return 'Звонил, но недозвонился: не удалось установить соединение';
}
return '';
}
async function applyLocalOutgoingDmCopy(peerLogin, result) {
const localOutgoingBlobB64 = String(result?.localOutgoingBlobB64 || '').trim();
if (!peerLogin || !localOutgoingBlobB64) return false;
try {
const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64);
const decrypted = await authService.decryptSignedMessageContent({
parsed,
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
});
addSignedMessageToChat({
chatId: peerLogin,
messageKey: String(result?.outgoingKey || parsed?.messageKey || ''),
baseKey: String(result?.baseKey || result?.localBaseKey || parsed?.baseKey || ''),
from: 'out',
text: String(decrypted?.text || ''),
messageType: Number(parsed?.messageType || 2),
unread: false,
rawBlobB64: localOutgoingBlobB64,
revisionTimeMs: Number(parsed?.revisionTimeMs || 0),
deleted: Boolean(parsed?.deleted),
});
return true;
} catch {
return false;
}
}
async function sendOutgoingCallSummaryDm(call, summaryCode) {
if (!call || call.direction !== 'out' || !call.peerLogin) return;
const text = buildOutgoingCallSummaryText(call, summaryCode);
if (!text) return;
const login = String(state?.session?.login || '').trim();
const storagePwd = String(state?.session?.storagePwdInMemory || '').trim();
if (!login || !storagePwd) return;
try {
const result = await authService.sendDirectMessage({
login,
toLogin: call.peerLogin,
text,
storagePwd,
});
await applyLocalOutgoingDmCopy(call.peerLogin, result);
} catch (error) {
await emitDebug(call, 'warn', 'call_summary_dm_failed', toErrorText(error));
}
}
function cloneDefaultIceServers() {
return DEFAULT_ICE_SERVERS.map((row) => ({ ...row }));
}
@@ -791,49 +873,6 @@ function startReconnectFlow(call, reason = 'disconnected') {
void runAttempt();
}
function pushCallSummary(call, summaryCode) {
if (!call?.peerLogin) return;
const outgoing = call.direction === 'out';
const from = outgoing ? 'out' : 'in';
if (summaryCode === 'busy') {
const text = outgoing
? '[Звонок] Исходящий: не дозвонились (занято)'
: `[Звонок] Пропущенный входящий от ${call.peerLogin} (занято)`;
addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' });
return;
}
if (summaryCode === 'no_answer') {
const text = outgoing
? '[Звонок] Исходящий: не дозвонились (нет ответа)'
: `[Звонок] Пропущенный входящий от ${call.peerLogin}`;
addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' });
return;
}
if (summaryCode === 'declined') {
const text = outgoing
? '[Звонок] Исходящий: не дозвонились (отклонён)'
: `[Звонок] Входящий звонок от ${call.peerLogin} отклонён`;
addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' });
return;
}
if (summaryCode === 'error') {
const text = outgoing
? '[Звонок] Исходящий: не дозвонились (ошибка соединения)'
: `[Звонок] Входящий звонок от ${call.peerLogin}: ошибка соединения`;
addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' });
return;
}
if (summaryCode === 'completed') {
const duration = formatDuration(nowMs() - Number(call.connectedAtMs || call.startedAtMs || nowMs()));
const label = outgoing ? 'Исходящий' : 'Входящий';
addSystemChatMessage(call.peerLogin, `[Звонок] ${label}: разговор ${duration}`, {
from,
kind: 'call-tech',
});
}
}
async function finalizeCall(call, {
localReasonCode = 'error',
debugReason = '',
@@ -902,7 +941,7 @@ async function finalizeCall(call, {
}
if (!suppressSummary) {
pushCallSummary(call, localReasonCode);
await sendOutgoingCallSummaryDm(call, localReasonCode);
}
call.phase = 'ended';
@@ -1424,7 +1463,7 @@ export async function startOutgoingCall(peerLogin) {
}, 35000);
try {
await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
call.inviteDelivery = await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
} catch (error) {
const text = String(error?.message || '').toUpperCase();
const isNotAuth = text.includes('NOT_AUTHENTICATED');
@@ -1432,7 +1471,7 @@ export async function startOutgoingCall(peerLogin) {
const recovered = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: true });
if (recovered) {
try {
await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
call.inviteDelivery = await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
return;
} catch {}
}