diff --git a/Dev_Docs/API/01_User_Registration_API.md b/Dev_Docs/API/01_User_Registration_API.md index 2f84d7e..1a1bb59 100644 --- a/Dev_Docs/API/01_User_Registration_API.md +++ b/Dev_Docs/API/01_User_Registration_API.md @@ -71,7 +71,8 @@ Важно: - это server-side existence-check; -- если пользователя нет в локальной БД, он может быть импортирован при авторизации из Solana PDA. +- если пользователя нет в локальной БД, сервер сразу пытается lazy-import из Solana PDA; +- поэтому `GetUser` можно использовать как актуальный способ получить `clientKey` и базовые поля пользователя перед E2EE DM. ### Запрос diff --git a/Dev_Docs/API/09_Operations_Index.md b/Dev_Docs/API/09_Operations_Index.md index c4c1bd2..2fad3d5 100644 --- a/Dev_Docs/API/09_Operations_Index.md +++ b/Dev_Docs/API/09_Operations_Index.md @@ -57,7 +57,6 @@ | `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга | | `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена | | `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка | -| `SendDirectMessage` | `12_Direct_Messages_Push_Calls_API.md` | отправка подписанного DM-пакета | | `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM | | `ReceiveOutcomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | алиас `SendMessagePair` | | `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока | @@ -70,6 +69,7 @@ ## Важные замечания - `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`. +- Legacy-операция `SendDirectMessage` больше не зарегистрирована и не должна использоваться для DM v1. - Отдельных HTTP endpoints для DM-файлов сейчас нет. - Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит. - HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`. diff --git a/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md b/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md index 859ed1b..f4a3b8f 100644 --- a/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md +++ b/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md @@ -7,6 +7,11 @@ - `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` - `Dev_Docs/Personal_Messages/Формат_DM_v1.md` +Важно: + +- legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API; +- для DM v1 нужно использовать только `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`. + ## 1. `UpsertPushToken` Требует авторизации. @@ -111,7 +116,7 @@ - `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64` - `400 / BAD_BLOCK_FORMAT` — base64 или бинарный контейнер повреждён - `400 / BAD_CRYPTO_METHOD` — неподдерживаемый метод шифрования контейнера `EncryptedBody_v1_0` -- `404 / USER_NOT_FOUND` — один из логинов не найден +- `404 / USER_NOT_FOUND` — один из логинов не найден даже после попытки lazy-import из Solana PDA - `460 / BAD_SIGNATURE` — подпись блока не прошла проверку ## 4. `ReceiveIncomingMessage` @@ -223,6 +228,8 @@ ## 11. Замечания - все DM-типы `1..8` используют `SHiNE_DM` +- `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE - сервер не расшифровывает DM и не использует ciphertext как preview текста -- сервер хранит последнюю применённую ревизию контентного сообщения и отдельные tombstone-события удаления +- сервер хранит последнюю применённую версию контентного сообщения по правилу `revisionTimeMs`, а при равенстве — по `reencryptedAtMs` +- если сервер уже знает tombstone удаления переписки и получает старое сообщение до этой границы, он перерассылает известный `DeleteConversation` на `access_servers` обеих сторон - HTTP endpoints для DM-файлов сейчас отсутствуют diff --git a/Dev_Docs/Pending_Features/2026-07-07_1730_dm_v1_e2ee_и_удаление.md b/Dev_Docs/Pending_Features/2026-07-07_1730_dm_v1_e2ee_и_удаление.md index 6128430..e581d8d 100644 --- a/Dev_Docs/Pending_Features/2026-07-07_1730_dm_v1_e2ee_и_удаление.md +++ b/Dev_Docs/Pending_Features/2026-07-07_1730_dm_v1_e2ee_и_удаление.md @@ -11,9 +11,13 @@ 5. Удаление исходящего сообщения отправителем (`type=5`) и удаление входящего сообщения получателем (`type=6`). 6. Удаление истории переписки (`type=7/8`) и игнорирование более старых сообщений после tombstone. 7. Сценарий с несколькими `access_servers`, включая случай общего сервера у обеих сторон. + 8. Первый DM пользователю, которого ещё нет в локальной `solana_users`, с lazy-import из Solana PDA. + 9. Две версии одного сообщения с одинаковым `revisionTimeMs`, но разным `reencryptedAtMs`: должна победить версия с большим `reencryptedAtMs`. + 10. После уже известного `DeleteConversation` отправка старого сообщения с другого сервера должна приводить к повторной рассылке tombstone удаления переписки. + 11. Попытка вызвать legacy `SendDirectMessage` должна не работать, а новый UI должен использовать только `SendMessagePair`. - ожидаемый результат: - Сообщения шифруются и расшифровываются только на клиенте, сервер не показывает plaintext из ciphertext, старые ревизии не оживают после tombstone, общие серверы не получают лишних дублей пары, а удаление и read-receipt корректно доходят обеим сторонам. + Сообщения шифруются и расшифровываются только на клиенте, сервер не показывает plaintext из ciphertext, lazy-import из PDA позволяет отправить первый DM без ручного прогрева БД, версия с большим `reencryptedAtMs` корректно замещает старую при равном `revisionTimeMs`, старые ревизии не оживают после tombstone, общие серверы не получают лишних дублей пары, а удаление и read-receipt корректно доходят обеим сторонам. - статус: pending diff --git a/Dev_Docs/Personal_Messages/README.md b/Dev_Docs/Personal_Messages/README.md index 5b9a67b..a84466a 100644 --- a/Dev_Docs/Personal_Messages/README.md +++ b/Dev_Docs/Personal_Messages/README.md @@ -15,3 +15,4 @@ - код DM и оба документа `Протокол_DM_v1.md` + `Формат_DM_v1.md` всегда должны обновляться синхронно; - если меняется поведение DM в коде, в том же наборе изменений обновляется и эта документация. +- локальная таблица пользователей для DM считается кэшем, а источником истины остаётся Solana PDA; если серверная логика DM меняет правила lazy-import пользователей из PDA, это тоже обязательно фиксируется в документации. diff --git a/Dev_Docs/Personal_Messages/Протокол_DM_v1.md b/Dev_Docs/Personal_Messages/Протокол_DM_v1.md index c439807..e7c4a9f 100644 --- a/Dev_Docs/Personal_Messages/Протокол_DM_v1.md +++ b/Dev_Docs/Personal_Messages/Протокол_DM_v1.md @@ -71,6 +71,19 @@ - сервер не должен расшифровывать DM; - ciphertext у `type=1` и `type=2` обычно разный и не обязан совпадать побайтно. +### 2.3. Источник истины по пользователю + +Для DM-проверки сервер использует: + +- локальную `solana_users` как кэш; +- Solana PDA как источник истины по `clientKey` и `access_servers`. + +Если нужного пользователя нет локально, сервер обязан попытаться lazy-import из Solana PDA: + +- в `GetUser`; +- при серверной верификации DM-подписи; +- перед межсерверной маршрутизацией через `access_servers`. + ## 3. Типы сообщений В версии v1 используются следующие `messageType`: @@ -135,7 +148,8 @@ - plaintext может остаться тем же; - ciphertext меняется; - `reencryptedAtMs` получает ненулевое значение; -- `revisionTimeMs` увеличивается. +- `revisionTimeMs` может остаться прежним, в том числе нулевым, если менялось только шифрование без редактирования текста; +- сервер при выборе актуальной версии обязан учитывать обе метки времени. Если сообщение уже удалено одним из типов `5/6`, его перешифровывать больше нельзя. @@ -223,7 +237,7 @@ то сервер: - не принимает это сообщение; -- в ответ возвращает то же сообщение удаления переписки; +- распространяет уже известный tombstone удаления переписки на серверы доступа обеих сторон; - ожидает, что вторая сторона обработает его как обычное уже известное удаление переписки. ### 6.5. Будущие новые сообщения @@ -281,6 +295,8 @@ - `SendMessagePair` - `ReceiveOutcomingMessage` - `ReceiveIncomingMessage` +- `DeleteMessage` +- `DeleteConversation` Их роли в v1: @@ -290,34 +306,27 @@ ### 8.2. Новые методы, которые нужны -Нужны как минимум отдельные операции: - -- `DeleteMessage` -- `DeleteConversation` - -Названия могут быть другими, но логически это две разные операции: - -- удалить одно сообщение у обеих сторон; -- удалить всю переписку до заданной временной границы. +Отдельный legacy-метод `SendDirectMessage` в DM v1 не используется и должен оставаться отключённым, чтобы не было параллельного старого стека доставки. ## 9. Правила валидации и применения ### 9.1. Общее правило по ревизиям -Для одного и того же `messageKey` сервер сравнивает только: +Для одного и того же контентного сообщения сервер сравнивает пару: -- уже сохранённый `revisionTimeMs`; -- `revisionTimeMs` входящего сообщения. +- `revisionTimeMs`; +- `reencryptedAtMs`. Правило: - если новый `revisionTimeMs` больше сохранённого, сообщение применяется; -- если новый `revisionTimeMs` равен сохранённому, сообщение не применяется; -- если новый `revisionTimeMs` меньше сохранённого, сообщение не применяется. +- если `revisionTimeMs` равен, но новый `reencryptedAtMs` больше сохранённого, сообщение применяется; +- если обе величины равны, сообщение не применяется; +- если новая пара (`revisionTimeMs`, `reencryptedAtMs`) меньше или равна сохранённой, сообщение не применяется. Содержимое `body` при этом сравнении не участвует. -То есть если `revisionTimeMs` совпадает, сервер считает, что такая ревизия у него уже есть. +То есть если сервер уже видел ту же пару (`revisionTimeMs`, `reencryptedAtMs`), он считает, что такая версия у него уже есть. ### 9.2. Повторный tombstone @@ -353,6 +362,8 @@ - не восстанавливает старую историю; - не применяет это событие повторно. +Если это старое контентное сообщение или входящая копия, сервер дополнительно перерассылает уже известный tombstone удаления переписки на `access_servers` обеих сторон, чтобы отстающие серверы сами подчистили историю. + Такие сообщения считаются частью уже удалённой истории. ### 9.4. Приоритет удаления переписки @@ -475,6 +486,8 @@ Request: Если часть серверов совпадает, это допустимо. +Если один и тот же сервер присутствует у обеих сторон, он не должен слать сообщение сам себе повторно, но обязан локально сохранить событие и доставить его в нужные пользовательские сессии. + Идемпотентность обязательна. ### 11.3. Ошибки доставки @@ -513,6 +526,9 @@ Request: - удаление одного сообщения должно стать терминальным tombstone; - удаление всей переписки должно стать отдельным служебным событием; - межсерверная маршрутизация DM должна идти через `access_servers`; +- сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM; +- при выборе актуальной версии должен учитываться `reencryptedAtMs`, если `revisionTimeMs` совпадает; +- legacy `SendDirectMessage` должен быть отключён; - логика должна быть безопасна для нескольких серверов у каждой стороны. ## 14. Что в v1 пока не входит diff --git a/Dev_Docs/Personal_Messages/Формат_DM_v1.md b/Dev_Docs/Personal_Messages/Формат_DM_v1.md index 57659bc..9f36e40 100644 --- a/Dev_Docs/Personal_Messages/Формат_DM_v1.md +++ b/Dev_Docs/Personal_Messages/Формат_DM_v1.md @@ -81,11 +81,12 @@ SHiNE_DM ### `revisionTimeMs` -- `0` для исходной версии сообщения; -- ненулевое значение для новой ревизии сообщения; -- при редактировании или удалении должно быть больше предыдущего значения для этого же `messageKey`. +- `0` для исходной версии контентного сообщения; +- ненулевое значение для новой текстовой ревизии сообщения; +- для типов `5/6` это время tombstone удаления одного сообщения; +- для типов `7/8` всегда `0`. -То есть это `0` или время, когда сообщение было отредактировано либо удалено. +То есть `revisionTimeMs` отвечает именно за изменение смыслового содержимого сообщения, а не за сам факт перешифровки. ### `reencryptedAtMs` @@ -94,6 +95,13 @@ SHiNE_DM Для типов `5/6/7/8` должно быть `0`. +`reencryptedAtMs` не требует, чтобы `revisionTimeMs` был ненулевым. Допустим сценарий: + +- `revisionTimeMs = 0` +- `reencryptedAtMs > 0` + +если исходное сообщение не редактировалось, но было перешифровано позже. + ### `bodyLen` - `> 0` для типов `1/2/3/4`; @@ -213,7 +221,7 @@ ReadReceiptBody_v1_0 ### Перешифровка -- `revisionTimeMs > 0` +- `revisionTimeMs = 0` или `> 0` - `reencryptedAtMs > 0` ## 8. Контент типов `3/4` diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java index 745e85d..530f4b4 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java @@ -11,6 +11,17 @@ import java.util.ArrayList; import java.util.List; public final class SignedMessagesV2DAO { + public enum ApplyStatus { + APPLIED, + DUPLICATE_OR_OLDER, + BLOCKED_BY_MESSAGE_TOMBSTONE, + BLOCKED_BY_CONVERSATION_TOMBSTONE; + + public boolean applied() { + return this == APPLIED; + } + } + private static volatile SignedMessagesV2DAO instance; private final SqliteDbController db = SqliteDbController.getInstance(); @@ -25,8 +36,11 @@ public final class SignedMessagesV2DAO { return instance; } - public boolean insertIfAbsent(SignedMessageV2Entry e) throws Exception { + public ApplyStatus insertIfAbsent(SignedMessageV2Entry e) throws Exception { try (Connection c = db.getConnection()) { + if (isBlockedByConversationDelete(c, e.getFromLogin(), e.getToLogin(), e.getTimeMs())) { + return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE; + } String sql = """ INSERT OR IGNORE INTO signed_messages_v2 ( message_key, base_key, target_login, from_login, to_login, @@ -37,7 +51,7 @@ public final class SignedMessagesV2DAO { """; try (PreparedStatement ps = c.prepareStatement(sql)) { bindSignedMessage(ps, e); - return ps.executeUpdate() > 0; + return ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER; } } } @@ -67,25 +81,25 @@ public final class SignedMessagesV2DAO { } } - public boolean upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception { + public ApplyStatus upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception { try (Connection c = db.getConnection()) { boolean prevAutoCommit = c.getAutoCommit(); c.setAutoCommit(false); try { if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) { c.rollback(); - return false; + return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE; } if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) { c.rollback(); - return false; + return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE; } - Long currentRevision = getCurrentContentRevision(c, incoming.getBaseKey()); - long nextRevision = incoming.getRevisionTimeMs(); - if (currentRevision != null && nextRevision <= currentRevision) { + RevisionMarker currentMarker = getCurrentContentMarker(c, incoming.getBaseKey()); + RevisionMarker nextMarker = RevisionMarker.of(incoming); + if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) { c.rollback(); - return false; + return ApplyStatus.DUPLICATE_OR_OLDER; } upsertMessage(c, incoming); @@ -94,7 +108,7 @@ public final class SignedMessagesV2DAO { resetDeliveryRows(c, outgoing.getMessageKey()); c.commit(); - return true; + return ApplyStatus.APPLIED; } catch (Exception ex) { try { c.rollback(); } catch (Exception ignored) {} throw ex; @@ -104,31 +118,31 @@ public final class SignedMessagesV2DAO { } } - public boolean upsertIncomingCopy(SignedMessageV2Entry incoming) throws Exception { + public ApplyStatus upsertIncomingCopy(SignedMessageV2Entry incoming) throws Exception { try (Connection c = db.getConnection()) { boolean prevAutoCommit = c.getAutoCommit(); c.setAutoCommit(false); try { if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) { c.rollback(); - return false; + return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE; } if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) { c.rollback(); - return false; + return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE; } - Long currentRevision = getRevisionTimeMs(c, incoming.getMessageKey()); - long nextRevision = incoming.getRevisionTimeMs(); - if (currentRevision != null && nextRevision <= currentRevision) { + RevisionMarker currentMarker = getRevisionMarkerByMessageKey(c, incoming.getMessageKey()); + RevisionMarker nextMarker = RevisionMarker.of(incoming); + if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) { c.rollback(); - return false; + return ApplyStatus.DUPLICATE_OR_OLDER; } upsertMessage(c, incoming); resetDeliveryRows(c, incoming.getMessageKey()); c.commit(); - return true; + return ApplyStatus.APPLIED; } catch (Exception ex) { try { c.rollback(); } catch (Exception ignored) {} throw ex; @@ -138,18 +152,18 @@ public final class SignedMessagesV2DAO { } } - public boolean applyDeleteMessage(SignedMessageV2Entry tombstone) throws Exception { + public ApplyStatus applyDeleteMessage(SignedMessageV2Entry tombstone) throws Exception { try (Connection c = db.getConnection()) { boolean prevAutoCommit = c.getAutoCommit(); c.setAutoCommit(false); try { if (isBlockedByConversationDelete(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs())) { c.rollback(); - return false; + return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE; } if (hasMessageDeleteTombstone(c, tombstone.getBaseKey())) { c.rollback(); - return false; + return ApplyStatus.DUPLICATE_OR_OLDER; } deleteMessageContentAndReceipts(c, tombstone.getBaseKey()); @@ -157,7 +171,7 @@ public final class SignedMessagesV2DAO { resetDeliveryRows(c, tombstone.getMessageKey()); c.commit(); - return true; + return ApplyStatus.APPLIED; } catch (Exception ex) { try { c.rollback(); } catch (Exception ignored) {} throw ex; @@ -167,7 +181,7 @@ public final class SignedMessagesV2DAO { } } - public boolean applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception { + public ApplyStatus applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception { try (Connection c = db.getConnection()) { boolean prevAutoCommit = c.getAutoCommit(); c.setAutoCommit(false); @@ -175,7 +189,7 @@ public final class SignedMessagesV2DAO { Long currentBoundary = getLatestConversationDeleteBoundary(c, tombstone.getFromLogin(), tombstone.getToLogin()); if (currentBoundary != null && tombstone.getTimeMs() <= currentBoundary) { c.rollback(); - return false; + return ApplyStatus.DUPLICATE_OR_OLDER; } deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs()); @@ -183,7 +197,7 @@ public final class SignedMessagesV2DAO { resetDeliveryRows(c, tombstone.getMessageKey()); c.commit(); - return true; + return ApplyStatus.APPLIED; } catch (Exception ex) { try { c.rollback(); } catch (Exception ignored) {} throw ex; @@ -214,6 +228,12 @@ public final class SignedMessagesV2DAO { } } + public SignedMessageV2Entry getLatestConversationDelete(String fromLogin, String toLogin) throws Exception { + try (Connection c = db.getConnection()) { + return getLatestConversationDelete(c, fromLogin, toLogin); + } + } + public void ensureDeliveryRow(String messageKey, String sessionId, long nowMs) throws Exception { try (Connection c = db.getConnection()) { String sql = """ @@ -338,30 +358,36 @@ public final class SignedMessagesV2DAO { } } - private Long getRevisionTimeMs(Connection c, String messageKey) throws SQLException { - String sql = "SELECT revision_time_ms FROM signed_messages_v2 WHERE message_key = ? LIMIT 1"; + private RevisionMarker getRevisionMarkerByMessageKey(Connection c, String messageKey) throws SQLException { + String sql = """ + SELECT revision_time_ms, reencrypted_at_ms + FROM signed_messages_v2 + WHERE message_key = ? + LIMIT 1 + """; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, messageKey); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) return null; - return rs.getLong(1); + return new RevisionMarker(rs.getLong(1), rs.getLong(2)); } } } - private Long getCurrentContentRevision(Connection c, String baseKey) throws SQLException { + private RevisionMarker getCurrentContentMarker(Connection c, String baseKey) throws SQLException { String sql = """ - SELECT MAX(revision_time_ms) + SELECT revision_time_ms, reencrypted_at_ms FROM signed_messages_v2 WHERE base_key = ? AND message_type IN (1, 2) + ORDER BY revision_time_ms DESC, reencrypted_at_ms DESC + LIMIT 1 """; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, baseKey); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) return null; - long value = rs.getLong(1); - return rs.wasNull() ? null : value; + return new RevisionMarker(rs.getLong(1), rs.getLong(2)); } } } @@ -410,6 +436,34 @@ public final class SignedMessagesV2DAO { } } + private SignedMessageV2Entry getLatestConversationDelete(Connection c, String fromLogin, String toLogin) throws Exception { + String sql = """ + SELECT + message_key, base_key, target_login, from_login, to_login, + time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, + raw_block, created_at_ms, source_api, origin_session_id, + receipt_ref_base_key, receipt_ref_type + FROM signed_messages_v2 + WHERE message_type IN (7, 8) + AND ( + (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + ) + ORDER BY time_ms DESC + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, fromLogin); + ps.setString(2, toLogin); + ps.setString(3, toLogin); + ps.setString(4, fromLogin); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + private void deleteMessageContentAndReceipts(Connection c, String baseKey) throws SQLException { deleteDeliveryRowsByMessageSelection(c, """ SELECT message_key @@ -532,6 +586,12 @@ public final class SignedMessagesV2DAO { return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key"); } + private int compareMarkers(RevisionMarker left, RevisionMarker right) { + int revisionCompare = Long.compare(left.revisionTimeMs, right.revisionTimeMs); + if (revisionCompare != 0) return revisionCompare; + return Long.compare(left.reencryptedAtMs, right.reencryptedAtMs); + } + private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception { SignedMessageV2Entry e = new SignedMessageV2Entry(); e.setMessageKey(rs.getString("message_key")); @@ -553,4 +613,10 @@ public final class SignedMessagesV2DAO { e.setReceiptRefType(rs.wasNull() ? null : maybeRefType); return e; } + + private record RevisionMarker(long revisionTimeMs, long reencryptedAtMs) { + private static RevisionMarker of(SignedMessageV2Entry entry) { + return new RevisionMarker(entry.getRevisionTimeMs(), entry.getReencryptedAtMs()); + } + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index a125682..2415774 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -195,7 +195,6 @@ public final class JsonHandlerRegistry { // --- direct messages / push --- Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()), Map.entry("SendTestWebPush", new Net_SendTestWebPush_Handler()), - Map.entry("SendDirectMessage", new Net_SendDirectMessage_Handler()), Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()), Map.entry("ReceiveOutcomingMessage", new Net_SendMessagePair_Handler()), Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()), @@ -276,7 +275,6 @@ public final class JsonHandlerRegistry { // --- direct messages / push --- Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class), Map.entry("SendTestWebPush", Net_SendTestWebPush_Request.class), - Map.entry("SendDirectMessage", Net_SendDirectMessage_Request.class), Map.entry("SendMessagePair", Net_SendMessagePair_Request.class), Map.entry("ReceiveOutcomingMessage", Net_SendMessagePair_Request.class), Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java index 9e28295..728b8d3 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java @@ -5,6 +5,7 @@ import org.slf4j.LoggerFactory; import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.entyties.Net_Request; import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Response; @@ -42,6 +43,9 @@ public class Net_GetUser_Handler implements JsonMessageHandler { try { SolanaUserEntry u = usersDAO.getByLogin(req.getLogin()); + if (u == null) { + u = SolanaUserPdaImportService.findOrImportByLogin(req.getLogin()); + } Net_GetUser_Response resp = new Net_GetUser_Response(); resp.setOp(req.getOp()); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java index 90744ad..a8a7f50 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java @@ -39,13 +39,13 @@ public class Net_DeleteConversation_Handler implements JsonMessageHandler { } SignedMessageV2Entry entry = SignedMessagesCore.toEntry(block, "DeleteConversation", null); - boolean applied = SignedMessagesV2DAO.getInstance().applyDeleteConversation(entry); + SignedMessagesV2DAO.ApplyStatus status = SignedMessagesV2DAO.getInstance().applyDeleteConversation(entry); SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); - if (applied) { + if (status.applied()) { counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block); } - if (applied && ctx != null && ctx.isAuthenticatedUser()) { + if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) { DmFederationService.fanOutDeleteConversation(block.fromLogin, block.toLogin, block.messageType, req.getBlobB64().trim()); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java index 09fef51..aac091c 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java @@ -39,13 +39,13 @@ public class Net_DeleteMessage_Handler implements JsonMessageHandler { } SignedMessageV2Entry entry = SignedMessagesCore.toEntry(block, "DeleteMessage", null); - boolean applied = SignedMessagesV2DAO.getInstance().applyDeleteMessage(entry); + SignedMessagesV2DAO.ApplyStatus status = SignedMessagesV2DAO.getInstance().applyDeleteMessage(entry); SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); - if (applied) { + if (status.applied()) { counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block); } - if (applied && ctx != null && ctx.isAuthenticatedUser()) { + if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) { DmFederationService.fanOutDeleteMessage(block.fromLogin, block.toLogin, block.messageType, req.getBlobB64().trim()); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java index 895ffee..1f87ffd 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java @@ -11,6 +11,8 @@ import server.logic.ws_protocol.WireCodes; import shine.db.dao.SignedMessagesV2DAO; import shine.db.entities.SignedMessageV2Entry; +import java.util.Base64; + public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { @Override public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { @@ -45,13 +47,16 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения"); } - boolean inserted = incoming.isContentType() + SignedMessagesV2DAO.ApplyStatus status = incoming.isContentType() ? SignedMessagesV2DAO.getInstance().upsertIncomingCopy(entry) : SignedMessagesV2DAO.getInstance().insertIfAbsent(entry); SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); - if (inserted) { + if (status.applied()) { counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming); } + if (status == SignedMessagesV2DAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { + bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin); + } Net_ReceiveIncomingMessage_Response resp = new Net_ReceiveIncomingMessage_Response(); resp.setOp(req.getOp()); @@ -67,4 +72,15 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { private boolean isBlank(String s) { return s == null || s.isBlank(); } + + private void bounceConversationDeleteIfKnown(String fromLogin, String toLogin) throws Exception { + SignedMessageV2Entry tombstone = SignedMessagesV2DAO.getInstance().getLatestConversationDelete(fromLogin, toLogin); + if (tombstone == null || tombstone.getRawBlock() == null || tombstone.getRawBlock().length == 0) return; + server.sync.DmFederationService.fanOutDeleteConversation( + tombstone.getFromLogin(), + tombstone.getToLogin(), + tombstone.getMessageType(), + Base64.getEncoder().encodeToString(tombstone.getRawBlock()) + ); + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java index 532dd20..d1baee6 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java @@ -12,6 +12,8 @@ import server.sync.DmFederationService; import shine.db.dao.SignedMessagesV2DAO; import shine.db.entities.SignedMessageV2Entry; +import java.util.Base64; + public class Net_SendMessagePair_Handler implements JsonMessageHandler { @Override public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { @@ -50,17 +52,19 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения"); } - boolean pairInserted; + SignedMessagesV2DAO.ApplyStatus pairStatus; if (incoming.isContentType()) { - pairInserted = SignedMessagesV2DAO.getInstance().upsertContentPair( + pairStatus = SignedMessagesV2DAO.getInstance().upsertContentPair( incomingEntry, outgoingEntry ); } else { - pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry); + pairStatus = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry) + ? SignedMessagesV2DAO.ApplyStatus.APPLIED + : SignedMessagesV2DAO.ApplyStatus.DUPLICATE_OR_OLDER; } SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters(); - if (pairInserted) { + if (pairStatus.applied()) { inCounters = SignedMessagesRealtime.deliverToRelevantSessions(incomingEntry, incoming); } @@ -69,11 +73,15 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { excludeSessionId = ctx.getSessionId(); } SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters(); - if (pairInserted) { + if (pairStatus.applied()) { outCounters = SignedMessagesRealtime.deliverToRelevantSessions(outgoingEntry, outgoing, excludeSessionId); } - if (pairInserted && ctx != null && ctx.isAuthenticatedUser()) { + if (pairStatus == SignedMessagesV2DAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { + bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin); + } + + if (pairStatus.applied() && ctx != null && ctx.isAuthenticatedUser()) { DmFederationService.fanOutPair( incoming.fromLogin, incoming.toLogin, @@ -97,4 +105,15 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { private boolean isBlank(String s) { return s == null || s.isBlank(); } + + private void bounceConversationDeleteIfKnown(String fromLogin, String toLogin) throws Exception { + SignedMessageV2Entry tombstone = SignedMessagesV2DAO.getInstance().getLatestConversationDelete(fromLogin, toLogin); + if (tombstone == null || tombstone.getRawBlock() == null || tombstone.getRawBlock().length == 0) return; + DmFederationService.fanOutDeleteConversation( + tombstone.getFromLogin(), + tombstone.getToLogin(), + tombstone.getMessageType(), + Base64.getEncoder().encodeToString(tombstone.getRawBlock()) + ); + } } 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 53fd8e1..40ad3ef 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 @@ -116,12 +116,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); - if (revisionTimeMs == 0 && reencryptedAtMs != 0) { - throw new IllegalArgumentException("BAD_REENCRYPTED_TIME"); - } - if (reencryptedAtMs > 0 && revisionTimeMs == 0) { - throw new IllegalArgumentException("BAD_REENCRYPTED_TIME"); - } } 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/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java index acf5333..f51c2eb 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java @@ -1,5 +1,6 @@ package server.logic.ws_protocol.JSON.messages; +import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService; import shine.db.dao.SolanaUsersDAO; import shine.db.entities.SignedMessageV2Entry; import shine.db.entities.SolanaUserEntry; @@ -46,8 +47,8 @@ final class SignedMessagesCore { } static void verifyUsersAndSignature(SignedMessageBlock block) throws Exception { - SolanaUserEntry from = SolanaUsersDAO.getInstance().getByLogin(block.fromLogin); - SolanaUserEntry to = SolanaUsersDAO.getInstance().getByLogin(block.toLogin); + SolanaUserEntry from = resolveUser(block.fromLogin); + SolanaUserEntry to = resolveUser(block.toLogin); if (from == null || to == null) { throw new IllegalArgumentException("USER_NOT_FOUND"); } @@ -60,6 +61,13 @@ final class SignedMessagesCore { } } + private static SolanaUserEntry resolveUser(String login) throws Exception { + SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance(); + SolanaUserEntry user = usersDao.getByLogin(login); + if (user != null) return user; + return SolanaUserPdaImportService.findOrImportByLogin(login); + } + static void validatePair(SignedMessageBlock incoming, SignedMessageBlock outgoing) { if (incoming.messageType % 2 == 0) throw new IllegalArgumentException("BAD_PAIR_TYPES"); if (outgoing.messageType != incoming.messageType + 1) throw new IllegalArgumentException("BAD_PAIR_TYPES"); diff --git a/VERSION.properties b/VERSION.properties index 9e2c655..f3595e5 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.299 -server.version=1.2.279 +client.version=1.2.300 +server.version=1.2.280 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 6d009bd..7dd17db 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -1088,7 +1088,7 @@ async function init() { let refBaseKey = String(payload.receiptRefBaseKey || '').trim(); if (!refBaseKey) { try { - const ref = authService.parseReadReceiptPayload(parsed.payloadBytes); + const ref = parsed.readReceiptPayload || authService.parseReadReceiptPayload(parsed.payloadBytes); refBaseKey = `${ref.refFromLogin}|${ref.refToLogin}|${ref.refTimeMs}|${ref.refNonce}`; } catch {} } diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index d57344a..627296f 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -330,6 +330,42 @@ function parseEncryptedDmBodyBytes(payloadBytes) { }; } +function parseReadReceiptBodyBytes(payloadBytes) { + if (!(payloadBytes instanceof Uint8Array)) throw new Error('BAD_RECEIPT_PAYLOAD_LEN'); + let o = 0; + const read = (n) => { + if (o + n > payloadBytes.length) throw new Error('BAD_RECEIPT_PAYLOAD_LEN'); + const out = payloadBytes.slice(o, o + n); + o += n; + return out; + }; + const readU8 = () => read(1)[0]; + const readU32 = () => { + const part = read(4); + return new DataView(part.buffer, part.byteOffset, 4).getUint32(0, false); + }; + const readU64 = () => { + const part = read(8); + return Number(new DataView(part.buffer, part.byteOffset, 8).getBigUint64(0, false)); + }; + const readAscii = (code) => { + const len = readU8(); + const part = read(len); + for (let i = 0; i < part.length; i += 1) { + const c = part[i]; + if (c < 0x20 || c > 0x7e) throw new Error(code); + } + return new TextDecoder().decode(part); + }; + + const refToLogin = readAscii('BAD_RECEIPT_TO_LOGIN'); + const refFromLogin = readAscii('BAD_RECEIPT_FROM_LOGIN'); + const refTimeMs = readU64(); + const refNonce = readU32(); + if (o !== payloadBytes.length) throw new Error('BAD_RECEIPT_PAYLOAD_LEN'); + return { refToLogin, refFromLogin, refTimeMs, refNonce }; +} + function parseSignedMessageBlockBytes(bytes) { if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array'); let o = 0; @@ -374,11 +410,17 @@ function parseSignedMessageBlockBytes(bytes) { read(DM_PREFIX_V1.length); const formatVersionMajor = readU8(); const formatVersionMinor = readU8(); + if (formatVersionMajor !== DM_FORMAT_VERSION_MAJOR || formatVersionMinor !== DM_FORMAT_VERSION_MINOR) { + throw new Error('BAD_FORMAT_VERSION'); + } const toLogin = readAscii(); const fromLogin = readAscii(); const timeMs = readU64(); const nonce = readU32(); const messageType = readU8(); + if (messageType < DM_TYPE_INCOMING || messageType > DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT) { + throw new Error('BAD_MESSAGE_TYPE'); + } const revisionTimeMs = readU64(); const reencryptedAtMs = readU64(); const bodyLen = readU32(); @@ -392,9 +434,20 @@ function parseSignedMessageBlockBytes(bytes) { let encryptedBodyPayload = null; 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) { - readReceiptPayload = null; + if (bodyBytes.length === 0) throw new Error('BAD_RECEIPT_PAYLOAD_LEN'); + if (revisionTimeMs !== 0 || reencryptedAtMs !== 0) throw new Error('BAD_RECEIPT_REVISION'); + readReceiptPayload = parseReadReceiptBodyBytes(bodyBytes); + } else if (messageType === DM_TYPE_MESSAGE_DELETED_BY_SENDER || messageType === DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT) { + if (bodyBytes.length !== 0) throw new Error('BAD_DELETE_BODY'); + if (revisionTimeMs <= 0) throw new Error('BAD_REVISION_TIME'); + if (reencryptedAtMs !== 0) throw new Error('BAD_REENCRYPTED_TIME'); + } else if (messageType === DM_TYPE_CONVERSATION_DELETED_BY_SENDER || messageType === DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT) { + if (bodyBytes.length !== 0) throw new Error('BAD_DELETE_BODY'); + if (revisionTimeMs !== 0) throw new Error('BAD_REVISION_TIME'); + if (reencryptedAtMs !== 0) throw new Error('BAD_REENCRYPTED_TIME'); } return { @@ -2278,34 +2331,7 @@ export class AuthService { } parseReadReceiptPayload(payloadBytes) { - if (!(payloadBytes instanceof Uint8Array)) throw new Error('Expected Uint8Array'); - let o = 0; - const read = (n) => { - if (o + n > payloadBytes.length) throw new Error('BAD_RECEIPT_LEN'); - const out = payloadBytes.slice(o, o + n); - o += n; - return out; - }; - const readU8 = () => read(1)[0]; - const readU32 = () => { - const part = read(4); - return new DataView(part.buffer, part.byteOffset, 4).getUint32(0, false); - }; - const readU64 = () => { - const part = read(8); - return Number(new DataView(part.buffer, part.byteOffset, 8).getBigUint64(0, false)); - }; - const readAscii = () => { - const len = readU8(); - const part = read(len); - return new TextDecoder().decode(part); - }; - const refToLogin = readAscii(); - const refFromLogin = readAscii(); - const refTimeMs = readU64(); - const refNonce = readU32(); - if (o !== payloadBytes.length) throw new Error('BAD_RECEIPT_LEN'); - return { refToLogin, refFromLogin, refTimeMs, refNonce }; + return parseReadReceiptBodyBytes(payloadBytes); } async decryptSignedMessageContent({ parsed, blobB64 = '', login, storagePwd }) {