SHA256
Сделать DM body opaque для сервера и улучшить fallback UI
This commit is contained in:
@@ -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`;
|
||||
|
||||
-1
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user