diff --git a/Dev_Docs/Pending_Features/2026-07-10_1248_call_summary_dm_from_initiator.md b/Dev_Docs/Pending_Features/2026-07-10_1248_call_summary_dm_from_initiator.md new file mode 100644 index 0000000..2db1f74 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-07-10_1248_call_summary_dm_from_initiator.md @@ -0,0 +1,17 @@ +# Итог звонка как DM от инициатора + +- краткое описание фичи: + Вместо локальных `call-tech` записей итог звонка теперь отправляется как обычное DM-сообщение только от стороны, которая инициировала звонок. + +- что именно проверять: + 1. Успешный звонок: после завершения инициатор отправляет в чат DM вида `Звонок: 37с` или `Звонок: 2м 14с`. + 2. Неуспешный звонок без ответа: инициатор отправляет `Звонил, но недозвонился: нет ответа`. + 3. Неуспешный звонок, когда у адресата нет доставленных сессий: инициатор отправляет `Звонил, но недозвонился: абонент не в сети`. + 4. Неуспешный звонок при проблеме соединения: инициатор отправляет `Звонил, но недозвонился: не удалось установить соединение`. + 5. Старые локальные `call-tech` bubble про итог звонка больше не появляются ни у инициатора, ни у принимающей стороны. + +- ожидаемый результат: + Итог звонка виден обеим сторонам как обычное DM-сообщение от инициатора, а локальные служебные записи про итог звонка больше не используются. + +- статус: + pending diff --git a/Dev_Docs/Pending_Features/2026-07-10_1748_dm_opaque_body_and_decrypt_fallback.md b/Dev_Docs/Pending_Features/2026-07-10_1748_dm_opaque_body_and_decrypt_fallback.md new file mode 100644 index 0000000..173118a --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-07-10_1748_dm_opaque_body_and_decrypt_fallback.md @@ -0,0 +1,23 @@ +## Краткое описание + +Сервер перестал валидировать внутреннюю crypto-структуру `body` у контентных DM `type=1/2`. +UI теперь не отбрасывает такие сообщения: если сообщение не удалось расшифровать, вместо текста показывается заглушка `Неудалось расшифровать сообщение`. + +## Что проверять + +- Отправка и приём обычных DM между штатными клиентами продолжают работать как раньше. +- Сообщение с незнакомым форматом `body` у `type=1/2` принимается сервером и доходит до клиента. +- В UI такое сообщение отображается в чате одной из ожидаемых заглушек: + - `Формат сообщения не поддерживается` + - `Не удалось расшифровать сообщение` + - `Сообщение повреждено` +- После выхода из аккаунта и повторного входа backlog с такими сообщениями тоже отображается с той же заглушкой. +- ACK доставки по сессии для такого сообщения не зацикливается и сообщение не приходит бесконечно повторно. + +## Ожидаемый результат + +Сервер выступает транспортом для opaque DM-body, а клиент при неудачной расшифровке показывает безопасный fallback вместо полного пропуска сообщения с различением основных причин. + +## Статус + +pending diff --git a/Dev_Docs/Personal_Messages/Протокол_DM_v1.md b/Dev_Docs/Personal_Messages/Протокол_DM_v1.md index f8e42c9..09a5053 100644 --- a/Dev_Docs/Personal_Messages/Протокол_DM_v1.md +++ b/Dev_Docs/Personal_Messages/Протокол_DM_v1.md @@ -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; - удаление всей переписки должно стать отдельным служебным событием; diff --git a/Dev_Docs/Personal_Messages/Формат_DM_v1.md b/Dev_Docs/Personal_Messages/Формат_DM_v1.md index 26c2856..2132bdc 100644 --- a/Dev_Docs/Personal_Messages/Формат_DM_v1.md +++ b/Dev_Docs/Personal_Messages/Формат_DM_v1.md @@ -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`; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java index 40ad3ef..9b975fa 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java @@ -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) { diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 36d062e..307739f 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -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= diff --git a/VERSION.properties b/VERSION.properties index e0bdb3b..9efdb21 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.306 -server.version=1.2.285 +client.version=1.2.307 +server.version=1.2.286 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 2b23d73..b3ea760 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -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({ diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 627296f..029201c 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -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); diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js index e14c3ff..96a5d97 100644 --- a/shine-UI/js/services/call-service.js +++ b/shine-UI/js/services/call-service.js @@ -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 {} }