diff --git a/Dev_Docs/API/09_Operations_Index.md b/Dev_Docs/API/09_Operations_Index.md index 9fce01e..c4c1bd2 100644 --- a/Dev_Docs/API/09_Operations_Index.md +++ b/Dev_Docs/API/09_Operations_Index.md @@ -61,6 +61,8 @@ | `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-блока | +| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон | +| `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки | | `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию | | `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку | | `CallSignalToSession` | `12_Direct_Messages_Push_Calls_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 0998f79..859ed1b 100644 --- a/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md +++ b/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md @@ -73,7 +73,7 @@ - `incomingBlobB64` — блок `type=1` или `type=3` - `outgoingBlobB64` — блок `type=2` или `type=4` -Для контентных сообщений `type=1/2` внутри base64 лежит бинарный формат `SHiNE_DM`. +Все типы в этой паре используют бинарный формат `SHiNE_DM`. ### Запрос @@ -110,8 +110,7 @@ - `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64` - `400 / BAD_BLOCK_FORMAT` — base64 или бинарный контейнер повреждён -- `400 / BAD_CONTENT_FORMAT` — для контентного сообщения пришёл не `SHiNE_DM` -- `400 / ATTACHMENTS_DISABLED` — в `SHiNE_DM` пришёл `attachmentsCount != 0` +- `400 / BAD_CRYPTO_METHOD` — неподдерживаемый метод шифрования контейнера `EncryptedBody_v1_0` - `404 / USER_NOT_FOUND` — один из логинов не найден - `460 / BAD_SIGNATURE` — подпись блока не прошла проверку @@ -123,6 +122,11 @@ Используется там, где нужно принять только incoming-вариант сообщения. +Принимаемые типы: + +- `type=1` — входящая копия сообщения +- `type=3` — входящий read-receipt + ### Запрос ```json @@ -135,7 +139,39 @@ } ``` -## 5. `AckSessionDelivery` +## 5. `DeleteMessage` + +Принимает один signed DM-блок `type=5` или `type=6`. + +### Запрос + +```json +{ + "op": "DeleteMessage", + "requestId": "dm-del-001", + "payload": { + "blobB64": "BASE64_DELETE_BLOCK" + } +} +``` + +## 6. `DeleteConversation` + +Принимает один signed DM-блок `type=7` или `type=8`. + +### Запрос + +```json +{ + "op": "DeleteConversation", + "requestId": "dm-del-all-001", + "payload": { + "blobB64": "BASE64_DELETE_CONVERSATION_BLOCK" + } +} +``` + +## 7. `AckSessionDelivery` Требует авторизации. Подтверждает доставку в текущую сессию. @@ -151,7 +187,7 @@ } ``` -## 6. Событие `SignedMessageArrived` +## 8. Событие `SignedMessageArrived` Сервер присылает его по WebSocket в активные сессии адресата. @@ -174,18 +210,19 @@ Если это новая ревизия того же письма, `messageKey` остаётся тем же, а `revisionTimeMs` меняется внутри бинарного блока. -## 7. `CallInviteBroadcast` +Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`. + +## 9. `CallInviteBroadcast` Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`. -## 8. `CallSignalToSession` +## 10. `CallSignalToSession` Требует авторизации. Шлёт сигнал звонка в конкретную сессию. -## 9. Замечания +## 11. Замечания -- read-receipt `type=3/4` пока остаются в legacy-формате `SHiNE_dm2` -- контентные DM `type=1/2` используют `SHiNE_DM` -- сервер хранит только последнюю версию контентного сообщения по `messageKey` -- удаление сообщения реализуется новой ревизией с пустым телом и `attachmentsCount = 0` +- все DM-типы `1..8` используют `SHiNE_DM` +- сервер не расшифровывает DM и не использует ciphertext как preview текста +- сервер хранит последнюю применённую ревизию контентного сообщения и отдельные tombstone-события удаления - 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 new file mode 100644 index 0000000..6128430 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-07-07_1730_dm_v1_e2ee_и_удаление.md @@ -0,0 +1,19 @@ +# DM v1: E2EE, tombstone и fan-out + +- краткое описание фичи: + Новый протокол личных сообщений `SHiNE_DM` v1: реальные зашифрованные тела `X25519 + HKDF-SHA256 + AES-256-GCM`, read-receipt в том же контейнере, удаление одного сообщения, удаление истории переписки и межсерверная доставка по `access_servers`. + +- что именно проверять: + 1. Отправка нового DM между двумя пользователями и чтение текста на стороне получателя. + 2. Отправка/чтение своей исходящей копии у отправителя. + 3. Редактирование сообщения с тем же `baseKey` и ростом `revisionTimeMs`. + 4. Подтверждение прочтения `type=3/4`. + 5. Удаление исходящего сообщения отправителем (`type=5`) и удаление входящего сообщения получателем (`type=6`). + 6. Удаление истории переписки (`type=7/8`) и игнорирование более старых сообщений после tombstone. + 7. Сценарий с несколькими `access_servers`, включая случай общего сервера у обеих сторон. + +- ожидаемый результат: + Сообщения шифруются и расшифровываются только на клиенте, сервер не показывает plaintext из ciphertext, старые ревизии не оживают после tombstone, общие серверы не получают лишних дублей пары, а удаление и read-receipt корректно доходят обеим сторонам. + +- статус: + pending diff --git a/Dev_Docs/Personal_Messages/README.md b/Dev_Docs/Personal_Messages/README.md new file mode 100644 index 0000000..5b9a67b --- /dev/null +++ b/Dev_Docs/Personal_Messages/README.md @@ -0,0 +1,17 @@ +# Личные сообщения SHiNE + +Эта папка содержит актуальную документацию по личным сообщениям SHiNE. + +Точка входа: + +- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers` +- `Dev_Docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM` + +Исторический устаревший документ сохранён отдельно: + +- `Dev_Docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md` + +Правило сопровождения: + +- код DM и оба документа `Протокол_DM_v1.md` + `Формат_DM_v1.md` всегда должны обновляться синхронно; +- если меняется поведение DM в коде, в том же наборе изменений обновляется и эта документация. diff --git a/Dev_Docs/Personal_Messages/Протокол_DM_v1.md b/Dev_Docs/Personal_Messages/Протокол_DM_v1.md index 62b80cd..c439807 100644 --- a/Dev_Docs/Personal_Messages/Протокол_DM_v1.md +++ b/Dev_Docs/Personal_Messages/Протокол_DM_v1.md @@ -2,13 +2,13 @@ ## Статус документа -Этот файл — актуальная логическая спецификация DM-протокола SHiNE следующего этапа. +Этот файл — актуальная логическая спецификация DM-протокола SHiNE v1. Важно: -- это целевая спецификация; -- в текущем коде она ещё реализована не полностью; -- при реализации код должен приводиться именно к этой спецификации, а не наоборот. +- это актуальная реализованная спецификация; +- код и документы по DM должны изменяться синхронно; +- при любых будущих изменениях DM сначала обновляется эта спецификация и соседний документ формата. Документ фиксирует: @@ -151,7 +151,6 @@ - новые входящие сообщения; - входящие обновления/редактирования; -- входящие служебные события удаления; - будущие входящие перешифрованные копии. ## 5. Удаление одного сообщения @@ -286,7 +285,7 @@ Их роли в v1: - `SendMessagePair` — новая пара сообщений и редактирование старой пары автором; -- `ReceiveIncomingMessage` — приём одной входящей копии, входящих редактирований и служебных DM-событий; +- `ReceiveIncomingMessage` — приём одной входящей копии, входящих редактирований и входящего read-receipt; - `ReceiveOutcomingMessage` — алиас `SendMessagePair`. ### 8.2. Новые методы, которые нужны @@ -399,7 +398,6 @@ Request: - приём одной входящей копии по схеме server-to-server; - приём входящего редактирования; -- приём служебного входящего события удаления; - приём входящего read-receipt. Request: diff --git a/Dev_Docs/Personal_Messages/Формат_DM_v1.md b/Dev_Docs/Personal_Messages/Формат_DM_v1.md index e81e584..57659bc 100644 --- a/Dev_Docs/Personal_Messages/Формат_DM_v1.md +++ b/Dev_Docs/Personal_Messages/Формат_DM_v1.md @@ -6,9 +6,9 @@ Важно: -- это целевой формат следующего этапа; -- в текущем коде он ещё реализован не полностью; -- особенно это касается перехода всех типов `1..8` на единый контейнер `SHiNE_DM`. +- это актуальный формат, реализованный в текущем коде; +- контейнер `SHiNE_DM` используется для всех типов `1..8`; +- при любых будущих изменениях DM этот документ обновляется одновременно с кодом. Логика протокола, API и поведение сервера описаны отдельно: @@ -156,6 +156,19 @@ Nonce/IV для `AES-GCM`. Сервер не должен трактовать это поле как UTF-8 текст и не должен пытаться расшифровывать его в обычной DM-логике. +### Точная схема для `cryptoMethod = 1`, `cryptoVersion = 0` + +- публичный ключ получателя для E2EE получается как стандартное преобразование `Ed25519 -> X25519`; +- отправитель генерирует ephemeral `X25519` приватный ключ и кладёт соответствующий `ephemeralPubKey` в контейнер; +- общий секрет вычисляется как `X25519(ephemeralPrivKey, recipientX25519PubKey)`; +- `HKDF-SHA256` использует: + - `salt = ephemeralPubKey || recipientX25519PubKey` + - `info = "SHiNE_DM|1|0|X25519+HKDF-SHA256+AES-256-GCM"` в ASCII + - `outputLen = 32` +- полученные `32` байта используются как ключ `AES-256-GCM`; +- поле `cipherText` хранит стандартный результат библиотеки `AES-GCM` в виде: + - `ciphertext || tag` + ## 6. Поле `body` для типов `3/4` Для read-receipt типов `3/4` поле `body` содержит открытый контейнер ссылки на исходное сообщение: @@ -291,8 +304,6 @@ ReadReceiptBody_v1_0 - ciphertext может различаться; - `reencryptedAtMs` при одной парной ревизии тоже должен совпадать. -## 13. Примечание о текущем коде +## 13. Примечание о поддержке -В текущем коде типы `3/4` пока ещё реализованы старым контейнером `SHiNE_dm2`. - -Для версии протокола v1 целевой формат фиксируется уже как единый `SHiNE_DM` для всех типов `1..8`. +В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`. diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index a1d781e..81eeb4b 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -622,7 +622,7 @@ public final class DatabaseInitializer { ON signed_direct_messages_history (to_login, created_at_ms); """); - // 13) signed_messages_v2 (универсальное хранилище блоков типов 1/2/3/4) + // 13) signed_messages_v2 (универсальное хранилище блоков типов 1..8) st.executeUpdate(""" CREATE TABLE IF NOT EXISTS signed_messages_v2 ( message_key TEXT NOT NULL PRIMARY KEY, @@ -634,6 +634,7 @@ public final class DatabaseInitializer { nonce INTEGER NOT NULL, message_type INTEGER NOT NULL, revision_time_ms INTEGER NOT NULL DEFAULT 0, + reencrypted_at_ms INTEGER NOT NULL DEFAULT 0, raw_block BLOB NOT NULL, created_at_ms INTEGER NOT NULL, source_api TEXT NOT NULL, diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java index c469c45..112fa1e 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java @@ -14,7 +14,7 @@ import java.sql.Statement; public final class SqliteDbController { private static volatile SqliteDbController instance; - private static final int LATEST_SCHEMA_VERSION = 9; + private static final int LATEST_SCHEMA_VERSION = 10; private final String jdbcUrl; @@ -92,6 +92,7 @@ public final class SqliteDbController { case 7 -> migrateToV7(); case 8 -> migrateToV8(); case 9 -> migrateToV9(); + case 10 -> migrateToV10(); default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); } } @@ -289,6 +290,25 @@ public final class SqliteDbController { } } + private void migrateToV10() { + try (Connection c = DriverManager.getConnection(jdbcUrl); + Statement st = c.createStatement()) { + c.setAutoCommit(false); + try { + ensureSignedMessagesReencryptedColumn(c, st); + setSchemaVersion(c, 10); + c.commit(); + } catch (Exception e) { + try { c.rollback(); } catch (Exception ignored) {} + throw new RuntimeException("DB migration to v10 failed", e); + } finally { + try { c.setAutoCommit(true); } catch (Exception ignored) {} + } + } catch (SQLException e) { + throw new RuntimeException("DB migration to v10 failed", e); + } + } + private static void ensureChat200StateTables(Statement st) throws SQLException { st.executeUpdate(""" CREATE TABLE IF NOT EXISTS chat200_state ( @@ -416,6 +436,13 @@ public final class SqliteDbController { } } + private static void ensureSignedMessagesReencryptedColumn(Connection c, Statement st) throws SQLException { + if (!tableExists(c, "signed_messages_v2")) return; + if (!columnExists(c, "signed_messages_v2", "reencrypted_at_ms")) { + st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN reencrypted_at_ms INTEGER NOT NULL DEFAULT 0"); + } + } + private static void dropDmFileTables(Statement st) throws SQLException { st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_login"); st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_message"); 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 01ffa31..745e85d 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 @@ -8,7 +8,6 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; public final class SignedMessagesV2DAO { @@ -31,9 +30,10 @@ public final class SignedMessagesV2DAO { String sql = """ INSERT OR IGNORE INTO signed_messages_v2 ( message_key, base_key, target_login, from_login, to_login, - time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms, - source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + 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 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """; try (PreparedStatement ps = c.prepareStatement(sql)) { bindSignedMessage(ps, e); @@ -72,22 +72,18 @@ public final class SignedMessagesV2DAO { boolean prevAutoCommit = c.getAutoCommit(); c.setAutoCommit(false); try { - Long currentIncomingRevision = getRevisionTimeMs(c, incoming.getMessageKey()); - Long currentOutgoingRevision = getRevisionTimeMs(c, outgoing.getMessageKey()); - long currentRevision = Math.max( - currentIncomingRevision != null ? currentIncomingRevision : Long.MIN_VALUE, - currentOutgoingRevision != null ? currentOutgoingRevision : Long.MIN_VALUE - ); - long nextRevision = incoming.getRevisionTimeMs(); - - if (currentRevision != Long.MIN_VALUE && nextRevision < currentRevision) { + if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) { c.rollback(); return false; } - if (currentRevision != Long.MIN_VALUE - && nextRevision == currentRevision - && hasSameRawBlock(c, incoming) - && hasSameRawBlock(c, outgoing)) { + if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) { + c.rollback(); + return false; + } + + Long currentRevision = getCurrentContentRevision(c, incoming.getBaseKey()); + long nextRevision = incoming.getRevisionTimeMs(); + if (currentRevision != null && nextRevision <= currentRevision) { c.rollback(); return false; } @@ -108,13 +104,103 @@ public final class SignedMessagesV2DAO { } } + public boolean 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; + } + if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) { + c.rollback(); + return false; + } + + Long currentRevision = getRevisionTimeMs(c, incoming.getMessageKey()); + long nextRevision = incoming.getRevisionTimeMs(); + if (currentRevision != null && nextRevision <= currentRevision) { + c.rollback(); + return false; + } + + upsertMessage(c, incoming); + resetDeliveryRows(c, incoming.getMessageKey()); + c.commit(); + return true; + } catch (Exception ex) { + try { c.rollback(); } catch (Exception ignored) {} + throw ex; + } finally { + c.setAutoCommit(prevAutoCommit); + } + } + } + + public boolean 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; + } + if (hasMessageDeleteTombstone(c, tombstone.getBaseKey())) { + c.rollback(); + return false; + } + + deleteMessageContentAndReceipts(c, tombstone.getBaseKey()); + upsertMessage(c, tombstone); + resetDeliveryRows(c, tombstone.getMessageKey()); + + c.commit(); + return true; + } catch (Exception ex) { + try { c.rollback(); } catch (Exception ignored) {} + throw ex; + } finally { + c.setAutoCommit(prevAutoCommit); + } + } + } + + public boolean applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception { + try (Connection c = db.getConnection()) { + boolean prevAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + Long currentBoundary = getLatestConversationDeleteBoundary(c, tombstone.getFromLogin(), tombstone.getToLogin()); + if (currentBoundary != null && tombstone.getTimeMs() <= currentBoundary) { + c.rollback(); + return false; + } + + deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs()); + upsertMessage(c, tombstone); + resetDeliveryRows(c, tombstone.getMessageKey()); + + c.commit(); + return true; + } catch (Exception ex) { + try { c.rollback(); } catch (Exception ignored) {} + throw ex; + } finally { + c.setAutoCommit(prevAutoCommit); + } + } + } + public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception { try (Connection c = db.getConnection()) { String sql = """ SELECT message_key, base_key, target_login, from_login, to_login, - time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms, - source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type + 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_key = ? """; @@ -180,26 +266,35 @@ public final class SignedMessagesV2DAO { ) SELECT m.message_key, ?, 0, NULL, ? FROM signed_messages_v2 m - WHERE m.target_login = ? COLLATE NOCASE + WHERE ( + (m.message_type IN (1, 3) AND m.to_login = ? COLLATE NOCASE) + OR (m.message_type IN (2, 4) AND m.from_login = ? COLLATE NOCASE) + OR (m.message_type IN (5, 6, 7, 8) + AND (m.from_login = ? COLLATE NOCASE OR m.to_login = ? COLLATE NOCASE)) + ) """; long now = System.currentTimeMillis(); try (PreparedStatement ps = c.prepareStatement(fillSql)) { ps.setString(1, sessionId); ps.setLong(2, now); ps.setString(3, login); + ps.setString(4, login); + ps.setString(5, login); + ps.setString(6, login); ps.executeUpdate(); } String sql = """ SELECT m.message_key, m.base_key, m.target_login, m.from_login, m.to_login, - m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.raw_block, m.created_at_ms, - m.source_api, m.origin_session_id, m.receipt_ref_base_key, m.receipt_ref_type + m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms, + m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id, + m.receipt_ref_base_key, m.receipt_ref_type FROM signed_messages_v2 m JOIN signed_message_session_delivery d ON d.message_key = m.message_key WHERE d.session_id = ? AND d.delivered = 0 - ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.created_at_ms ASC + ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.reencrypted_at_ms ASC, m.created_at_ms ASC """; List out = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -216,9 +311,10 @@ public final class SignedMessagesV2DAO { String sql = """ INSERT INTO signed_messages_v2 ( message_key, base_key, target_login, from_login, to_login, - time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms, - source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + 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 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(message_key) DO UPDATE SET base_key = excluded.base_key, target_login = excluded.target_login, @@ -228,6 +324,7 @@ public final class SignedMessagesV2DAO { nonce = excluded.nonce, message_type = excluded.message_type, revision_time_ms = excluded.revision_time_ms, + reencrypted_at_ms = excluded.reencrypted_at_ms, raw_block = excluded.raw_block, created_at_ms = excluded.created_at_ms, source_api = excluded.source_api, @@ -252,17 +349,122 @@ public final class SignedMessagesV2DAO { } } - private boolean hasSameRawBlock(Connection c, SignedMessageV2Entry entry) throws SQLException { - String sql = "SELECT raw_block FROM signed_messages_v2 WHERE message_key = ? LIMIT 1"; + private Long getCurrentContentRevision(Connection c, String baseKey) throws SQLException { + String sql = """ + SELECT MAX(revision_time_ms) + FROM signed_messages_v2 + WHERE base_key = ? + AND message_type IN (1, 2) + """; try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, entry.getMessageKey()); + ps.setString(1, baseKey); try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return false; - return Arrays.equals(rs.getBytes(1), entry.getRawBlock()); + if (!rs.next()) return null; + long value = rs.getLong(1); + return rs.wasNull() ? null : value; } } } + private boolean hasMessageDeleteTombstone(Connection c, String baseKey) throws SQLException { + String sql = """ + SELECT 1 + FROM signed_messages_v2 + WHERE base_key = ? + AND message_type IN (5, 6) + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, baseKey); + try (ResultSet rs = ps.executeQuery()) { + return rs.next(); + } + } + } + + private boolean isBlockedByConversationDelete(Connection c, String fromLogin, String toLogin, long timeMs) throws SQLException { + Long boundary = getLatestConversationDeleteBoundary(c, fromLogin, toLogin); + return boundary != null && timeMs < boundary; + } + + private Long getLatestConversationDeleteBoundary(Connection c, String fromLogin, String toLogin) throws SQLException { + String sql = """ + SELECT MAX(time_ms) + 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) + ) + """; + 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; + long value = rs.getLong(1); + return rs.wasNull() ? null : value; + } + } + } + + private void deleteMessageContentAndReceipts(Connection c, String baseKey) throws SQLException { + deleteDeliveryRowsByMessageSelection(c, """ + SELECT message_key + FROM signed_messages_v2 + WHERE (base_key = ? AND message_type IN (1, 2)) + OR (receipt_ref_base_key = ? AND message_type IN (3, 4)) + """, baseKey, baseKey); + + try (PreparedStatement ps = c.prepareStatement(""" + DELETE FROM signed_messages_v2 + WHERE (base_key = ? AND message_type IN (1, 2)) + OR (receipt_ref_base_key = ? AND message_type IN (3, 4)) + """)) { + ps.setString(1, baseKey); + ps.setString(2, baseKey); + ps.executeUpdate(); + } + } + + private void deleteConversationHistoryBefore(Connection c, String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException { + deleteDeliveryRowsByMessageSelection(c, """ + SELECT message_key + FROM signed_messages_v2 + WHERE time_ms < ? + AND ( + (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + ) + """, boundaryTimeMs, fromLogin, toLogin, toLogin, fromLogin); + + try (PreparedStatement ps = c.prepareStatement(""" + DELETE FROM signed_messages_v2 + WHERE time_ms < ? + AND ( + (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + ) + """)) { + ps.setLong(1, boundaryTimeMs); + ps.setString(2, fromLogin); + ps.setString(3, toLogin); + ps.setString(4, toLogin); + ps.setString(5, fromLogin); + ps.executeUpdate(); + } + } + + private void deleteDeliveryRowsByMessageSelection(Connection c, String messageKeySelectSql, Object... bindValues) throws SQLException { + String sql = "DELETE FROM signed_message_session_delivery WHERE message_key IN (" + messageKeySelectSql + ")"; + try (PreparedStatement ps = c.prepareStatement(sql)) { + bindObjects(ps, bindValues); + ps.executeUpdate(); + } + } + private void resetDeliveryRows(Connection c, String messageKey) throws SQLException { try (PreparedStatement ps = c.prepareStatement(""" UPDATE signed_message_session_delivery @@ -278,9 +480,10 @@ public final class SignedMessagesV2DAO { String sql = """ INSERT INTO signed_messages_v2 ( message_key, base_key, target_login, from_login, to_login, - time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms, - source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + 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 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """; try (PreparedStatement ps = c.prepareStatement(sql)) { bindSignedMessage(ps, e); @@ -298,13 +501,30 @@ public final class SignedMessagesV2DAO { ps.setLong(7, e.getNonce()); ps.setInt(8, e.getMessageType()); ps.setLong(9, e.getRevisionTimeMs()); - ps.setBytes(10, e.getRawBlock()); - ps.setLong(11, e.getCreatedAtMs()); - ps.setString(12, e.getSourceApi()); - ps.setString(13, e.getOriginSessionId()); - ps.setString(14, e.getReceiptRefBaseKey()); - if (e.getReceiptRefType() == null) ps.setObject(15, null); - else ps.setInt(15, e.getReceiptRefType()); + ps.setLong(10, e.getReencryptedAtMs()); + ps.setBytes(11, e.getRawBlock()); + ps.setLong(12, e.getCreatedAtMs()); + ps.setString(13, e.getSourceApi()); + ps.setString(14, e.getOriginSessionId()); + ps.setString(15, e.getReceiptRefBaseKey()); + if (e.getReceiptRefType() == null) ps.setObject(16, null); + else ps.setInt(16, e.getReceiptRefType()); + } + + private void bindObjects(PreparedStatement ps, Object... bindValues) throws SQLException { + for (int i = 0; i < bindValues.length; i++) { + Object value = bindValues[i]; + int param = i + 1; + if (value instanceof String s) { + ps.setString(param, s); + } else if (value instanceof Long l) { + ps.setLong(param, l); + } else if (value instanceof Integer n) { + ps.setInt(param, n); + } else { + ps.setObject(param, value); + } + } } private boolean isConstraintViolation(SQLException ex) { @@ -323,6 +543,7 @@ public final class SignedMessagesV2DAO { e.setNonce(rs.getLong("nonce")); e.setMessageType(rs.getInt("message_type")); e.setRevisionTimeMs(rs.getLong("revision_time_ms")); + e.setReencryptedAtMs(rs.getLong("reencrypted_at_ms")); e.setRawBlock(rs.getBytes("raw_block")); e.setCreatedAtMs(rs.getLong("created_at_ms")); e.setSourceApi(rs.getString("source_api")); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java index 59e86dc..84e4bdf 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java @@ -10,6 +10,7 @@ public class SignedMessageV2Entry { private long nonce; private int messageType; private long revisionTimeMs; + private long reencryptedAtMs; private byte[] rawBlock; private long createdAtMs; private String sourceApi; @@ -35,6 +36,8 @@ public class SignedMessageV2Entry { public void setMessageType(int messageType) { this.messageType = messageType; } public long getRevisionTimeMs() { return revisionTimeMs; } public void setRevisionTimeMs(long revisionTimeMs) { this.revisionTimeMs = revisionTimeMs; } + public long getReencryptedAtMs() { return reencryptedAtMs; } + public void setReencryptedAtMs(long reencryptedAtMs) { this.reencryptedAtMs = reencryptedAtMs; } public byte[] getRawBlock() { return rawBlock; } public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; } public long getCreatedAtMs() { return createdAtMs; } 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 46981cc..a125682 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 @@ -89,6 +89,8 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListConta import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler; +import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler; +import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler; import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler; import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler; import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler; @@ -98,6 +100,8 @@ import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler; import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request; @@ -195,6 +199,8 @@ public final class JsonHandlerRegistry { Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()), Map.entry("ReceiveOutcomingMessage", new Net_SendMessagePair_Handler()), Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()), + Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()), + Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()), Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()), Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()), Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()), @@ -274,6 +280,8 @@ public final class JsonHandlerRegistry { Map.entry("SendMessagePair", Net_SendMessagePair_Request.class), Map.entry("ReceiveOutcomingMessage", Net_SendMessagePair_Request.class), Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class), + Map.entry("DeleteMessage", Net_DeleteMessage_Request.class), + Map.entry("DeleteConversation", Net_DeleteConversation_Request.class), Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class), Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class), Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java index 142afa9..60065c9 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java @@ -16,8 +16,10 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Base64; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; /** * Lazy-import пользователя из Solana PDA в локальную БД сервера. @@ -99,6 +101,28 @@ public final class SolanaUserPdaImportService { return parsed; } + public static List fetchAccessServerRoutesByLogin(String loginRaw) throws Exception { + String login = normalizeLogin(loginRaw); + if (login == null) return List.of(); + + ParsedSolanaUser parsed = fetchFromSolana(login); + if (parsed == null) return List.of(); + + Map routes = new LinkedHashMap<>(); + for (String accessServerLogin : parsed.accessServers()) { + String normalized = normalizeLogin(accessServerLogin); + if (normalized == null) continue; + + ParsedServerProfile serverProfile = fetchServerProfileByLogin(normalized); + if (serverProfile == null || !serverProfile.isServer()) continue; + + String serverAddress = safe(serverProfile.serverAddress()); + if (serverAddress.isBlank()) continue; + routes.putIfAbsent(normalized, new ParsedServerRoute(normalized, serverAddress)); + } + return new ArrayList<>(routes.values()); + } + private static ParsedSolanaUser fetchFromSolana(String login) throws Exception { byte[] raw = fetchRawUserPda(login); if (raw == null) return null; @@ -184,6 +208,7 @@ public final class SolanaUserPdaImportService { byte[] clientKey32 = null; long paidLimitBytes = 0L; List sessions = new ArrayList<>(); + List accessServers = new ArrayList<>(); for (int i = 0; i < blocksCount; i++) { int blockType = u8(raw, c++); @@ -239,7 +264,9 @@ public final class SolanaUserPdaImportService { int accessCount = u8(raw, c++); for (int j = 0; j < accessCount; j++) { int n = u8(raw, c++); + String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8); c += n; + accessServers.add(normalizeLogin(accessServerLogin)); } } else if (blockType == 50) { int sessionsMode = u8(raw, c++); @@ -277,7 +304,8 @@ public final class SolanaUserPdaImportService { Base64.getEncoder().encodeToString(blockchainKey32), Base64.getEncoder().encodeToString(clientKey32), paidLimitBytes, - sessions + sessions, + accessServers ); } @@ -438,13 +466,18 @@ public final class SolanaUserPdaImportService { return diff == 0; } + private static String safe(String value) { + return value == null ? "" : value.trim(); + } + private record ParsedSolanaUser( String login, String blockchainName, String blockchainKeyB64, String clientKeyB64, long paidLimitBytes, - List sessions + List sessions, + List accessServers ) {} private record ParsedSessionRecord( @@ -479,4 +512,9 @@ public final class SolanaUserPdaImportService { String serverAddress, List syncServers ) {} + + public record ParsedServerRoute( + String login, + String serverAddress + ) {} } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/EncryptedBodyPayload.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/EncryptedBodyPayload.java new file mode 100644 index 0000000..ec99654 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/EncryptedBodyPayload.java @@ -0,0 +1,70 @@ +package server.logic.ws_protocol.JSON.messages; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +final class EncryptedBodyPayload { + static final int CRYPTO_METHOD_X25519_HKDF_AES_GCM = 1; + + final int cryptoMethod; + final int cryptoVersion; + final byte[] ephemeralPubKey; + final byte[] iv; + final byte[] cipherText; + + private EncryptedBodyPayload( + int cryptoMethod, + int cryptoVersion, + byte[] ephemeralPubKey, + byte[] iv, + byte[] cipherText + ) { + this.cryptoMethod = cryptoMethod; + this.cryptoVersion = cryptoVersion; + this.ephemeralPubKey = ephemeralPubKey; + this.iv = iv; + this.cipherText = cipherText; + } + + static EncryptedBodyPayload parse(byte[] body, int maxBodyBytes) { + if (body == null || body.length < 1 + 1 + 1 + 1 + 4) { + throw new IllegalArgumentException("BAD_ENCRYPTED_BODY"); + } + ByteBuffer bb = ByteBuffer.wrap(body).order(ByteOrder.BIG_ENDIAN); + + int cryptoMethod = Byte.toUnsignedInt(bb.get()); + int cryptoVersion = Byte.toUnsignedInt(bb.get()); + if (cryptoMethod != CRYPTO_METHOD_X25519_HKDF_AES_GCM || cryptoVersion != 0) { + throw new IllegalArgumentException("BAD_CRYPTO_METHOD"); + } + + int ephemeralPubKeyLen = Byte.toUnsignedInt(bb.get()); + if (ephemeralPubKeyLen <= 0 || bb.remaining() < ephemeralPubKeyLen) { + throw new IllegalArgumentException("BAD_ENCRYPTED_BODY"); + } + byte[] ephemeralPubKey = new byte[ephemeralPubKeyLen]; + bb.get(ephemeralPubKey); + + int ivLen = Byte.toUnsignedInt(bb.get()); + if (ivLen <= 0 || bb.remaining() < ivLen + 4) { + throw new IllegalArgumentException("BAD_ENCRYPTED_BODY"); + } + byte[] iv = new byte[ivLen]; + bb.get(iv); + + long cipherTextLen = Integer.toUnsignedLong(bb.getInt()); + if (cipherTextLen <= 0 || cipherTextLen > maxBodyBytes || bb.remaining() != cipherTextLen) { + throw new IllegalArgumentException("BAD_ENCRYPTED_BODY"); + } + byte[] cipherText = new byte[(int) cipherTextLen]; + bb.get(cipherText); + + return new EncryptedBodyPayload( + cryptoMethod, + cryptoVersion, + ephemeralPubKey, + iv, + cipherText + ); + } +} 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 new file mode 100644 index 0000000..90744ad --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java @@ -0,0 +1,66 @@ +package server.logic.ws_protocol.JSON.messages; + +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.JsonMessageHandler; +import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import server.sync.DmFederationService; +import shine.db.dao.SignedMessagesV2DAO; +import shine.db.entities.SignedMessageV2Entry; + +public class Net_DeleteConversation_Handler implements JsonMessageHandler { + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_DeleteConversation_Request req = (Net_DeleteConversation_Request) baseRequest; + if (isBlank(req.getBlobB64())) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "blobB64 обязателен"); + } + + final SignedMessageBlock block; + try { + block = SignedMessagesCore.parseFromB64(req.getBlobB64()); + } catch (IllegalArgumentException ex) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат блока удаления переписки"); + } + if (!block.isConversationDeleteType()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_MESSAGE_TYPE", "DeleteConversation принимает только типы 7/8"); + } + + try { + SignedMessagesCore.verifyUsersAndSignature(block); + } catch (IllegalArgumentException ex) { + String code = ex.getMessage(); + int status = "USER_NOT_FOUND".equals(code) ? 404 : WireCodes.Status.UNVERIFIED; + return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку"); + } + + SignedMessageV2Entry entry = SignedMessagesCore.toEntry(block, "DeleteConversation", null); + boolean applied = SignedMessagesV2DAO.getInstance().applyDeleteConversation(entry); + + SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); + if (applied) { + counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block); + } + if (applied && ctx != null && ctx.isAuthenticatedUser()) { + DmFederationService.fanOutDeleteConversation(block.fromLogin, block.toLogin, block.messageType, req.getBlobB64().trim()); + } + + Net_DeleteConversation_Response resp = new Net_DeleteConversation_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setMessageKey(entry.getMessageKey()); + resp.setBaseKey(entry.getBaseKey()); + resp.setDeliveredWsSessions(counters.wsDelivered); + resp.setDeliveredWebPushSessions(counters.pushDelivered); + return resp; + } + + private boolean isBlank(String s) { + return s == null || s.isBlank(); + } +} 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 new file mode 100644 index 0000000..09fef51 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java @@ -0,0 +1,66 @@ +package server.logic.ws_protocol.JSON.messages; + +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.JsonMessageHandler; +import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import server.sync.DmFederationService; +import shine.db.dao.SignedMessagesV2DAO; +import shine.db.entities.SignedMessageV2Entry; + +public class Net_DeleteMessage_Handler implements JsonMessageHandler { + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_DeleteMessage_Request req = (Net_DeleteMessage_Request) baseRequest; + if (isBlank(req.getBlobB64())) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "blobB64 обязателен"); + } + + final SignedMessageBlock block; + try { + block = SignedMessagesCore.parseFromB64(req.getBlobB64()); + } catch (IllegalArgumentException ex) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат удаляющего блока"); + } + if (!block.isMessageDeleteType()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_MESSAGE_TYPE", "DeleteMessage принимает только типы 5/6"); + } + + try { + SignedMessagesCore.verifyUsersAndSignature(block); + } catch (IllegalArgumentException ex) { + String code = ex.getMessage(); + int status = "USER_NOT_FOUND".equals(code) ? 404 : WireCodes.Status.UNVERIFIED; + return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку"); + } + + SignedMessageV2Entry entry = SignedMessagesCore.toEntry(block, "DeleteMessage", null); + boolean applied = SignedMessagesV2DAO.getInstance().applyDeleteMessage(entry); + + SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); + if (applied) { + counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block); + } + if (applied && ctx != null && ctx.isAuthenticatedUser()) { + DmFederationService.fanOutDeleteMessage(block.fromLogin, block.toLogin, block.messageType, req.getBlobB64().trim()); + } + + Net_DeleteMessage_Response resp = new Net_DeleteMessage_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setMessageKey(entry.getMessageKey()); + resp.setBaseKey(entry.getBaseKey()); + resp.setDeliveredWsSessions(counters.wsDelivered); + resp.setDeliveredWebPushSessions(counters.pushDelivered); + return resp; + } + + private boolean isBlank(String s) { + return s == null || s.isBlank(); + } +} 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 a1f9faf..895ffee 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 @@ -25,7 +25,8 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { } catch (IllegalArgumentException ex) { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат входящего блока"); } - if (!incoming.isIncomingType()) { + if (incoming.messageType != SignedMessageBlock.TYPE_INCOMING_TEXT + && incoming.messageType != SignedMessageBlock.TYPE_READ_INCOMING) { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_MESSAGE_TYPE", "API принимает только входящие типы 1/3"); } @@ -44,10 +45,12 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения"); } - boolean inserted = SignedMessagesV2DAO.getInstance().insertIfAbsent(entry); + boolean inserted = incoming.isContentType() + ? SignedMessagesV2DAO.getInstance().upsertIncomingCopy(entry) + : SignedMessagesV2DAO.getInstance().insertIfAbsent(entry); SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (inserted) { - counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null); + counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming); } Net_ReceiveIncomingMessage_Response resp = new Net_ReceiveIncomingMessage_Response(); 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 db0f9e8..532dd20 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 @@ -8,6 +8,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Reque import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; +import server.sync.DmFederationService; import shine.db.dao.SignedMessagesV2DAO; import shine.db.entities.SignedMessageV2Entry; @@ -41,7 +42,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { SignedMessageV2Entry incomingEntry; SignedMessageV2Entry outgoingEntry; try { - String sourceApi = "ReceiveOutcomingMessage"; + String sourceApi = "SendMessagePair"; String originSessionId = (ctx != null && !isBlank(ctx.getSessionId())) ? ctx.getSessionId() : null; incomingEntry = SignedMessagesCore.toEntry(incoming, sourceApi, originSessionId); outgoingEntry = SignedMessagesCore.toEntry(outgoing, sourceApi, originSessionId); @@ -60,7 +61,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters(); if (pairInserted) { - inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, incoming); + inCounters = SignedMessagesRealtime.deliverToRelevantSessions(incomingEntry, incoming); } String excludeSessionId = null; @@ -69,7 +70,16 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { } SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters(); if (pairInserted) { - outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, outgoing, excludeSessionId); + outCounters = SignedMessagesRealtime.deliverToRelevantSessions(outgoingEntry, outgoing, excludeSessionId); + } + + if (pairInserted && ctx != null && ctx.isAuthenticatedUser()) { + DmFederationService.fanOutPair( + incoming.fromLogin, + incoming.toLogin, + req.getIncomingBlobB64().trim(), + req.getOutgoingBlobB64().trim() + ); } Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/ReadReceiptPayload.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/ReadReceiptPayload.java index 9808453..fd7c16f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/ReadReceiptPayload.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/ReadReceiptPayload.java @@ -9,18 +9,16 @@ final class ReadReceiptPayload { final String refFromLogin; final long refTimeMs; final long refNonce; - final int refType; - private ReadReceiptPayload(String refToLogin, String refFromLogin, long refTimeMs, long refNonce, int refType) { + private ReadReceiptPayload(String refToLogin, String refFromLogin, long refTimeMs, long refNonce) { this.refToLogin = refToLogin; this.refFromLogin = refFromLogin; this.refTimeMs = refTimeMs; this.refNonce = refNonce; - this.refType = refType; } static ReadReceiptPayload parse(byte[] payload) { - if (payload == null || payload.length < 1 + 1 + 8 + 4 + 2) { + if (payload == null || payload.length < 1 + 1 + 8 + 4) { throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN"); } ByteBuffer bb = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN); @@ -29,14 +27,10 @@ final class ReadReceiptPayload { long refTimeMs = bb.getLong(); if (refTimeMs < 0) throw new IllegalArgumentException("BAD_RECEIPT_TIME"); long refNonce = Integer.toUnsignedLong(bb.getInt()); - int refType = Short.toUnsignedInt(bb.getShort()); - if (refType < SignedMessageBlock.TYPE_INCOMING_TEXT || refType > SignedMessageBlock.TYPE_READ_OUTGOING_COPY) { - throw new IllegalArgumentException("BAD_RECEIPT_REF_TYPE"); - } if (bb.hasRemaining()) { throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN"); } - return new ReadReceiptPayload(refTo, refFrom, refTimeMs, refNonce, refType); + return new ReadReceiptPayload(refTo, refFrom, refTimeMs, refNonce); } String refBaseKey() { 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 4528abf..53fd8e1 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 @@ -6,12 +6,16 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; final class SignedMessageBlock { - static final byte[] LEGACY_PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII); - static final byte[] V1_PREFIX = "SHiNE_DM".getBytes(StandardCharsets.US_ASCII); + static final byte[] PREFIX = "SHiNE_DM".getBytes(StandardCharsets.US_ASCII); + static final int TYPE_INCOMING_TEXT = 1; static final int TYPE_OUTGOING_COPY = 2; static final int TYPE_READ_INCOMING = 3; static final int TYPE_READ_OUTGOING_COPY = 4; + static final int TYPE_MESSAGE_DELETED_BY_SENDER = 5; + static final int TYPE_MESSAGE_DELETED_BY_RECIPIENT = 6; + static final int TYPE_CONVERSATION_DELETED_BY_SENDER = 7; + static final int TYPE_CONVERSATION_DELETED_BY_RECIPIENT = 8; final String toLogin; final String fromLogin; @@ -19,14 +23,15 @@ final class SignedMessageBlock { final long nonce; final int messageType; final long revisionTimeMs; + final long reencryptedAtMs; final int formatVersionMajor; final int formatVersionMinor; - final byte[] payloadBytes; - final byte[] encryptedBodyBytes; + final byte[] bodyBytes; + final EncryptedBodyPayload encryptedBody; + final ReadReceiptPayload readReceiptBody; final byte[] signedBody; final byte[] signature64; final byte[] rawPacket; - final boolean legacyFormat; private SignedMessageBlock( String toLogin, @@ -35,14 +40,15 @@ final class SignedMessageBlock { long nonce, int messageType, long revisionTimeMs, + long reencryptedAtMs, int formatVersionMajor, int formatVersionMinor, - byte[] payloadBytes, - byte[] encryptedBodyBytes, + byte[] bodyBytes, + EncryptedBodyPayload encryptedBody, + ReadReceiptPayload readReceiptBody, byte[] signedBody, byte[] signature64, - byte[] rawPacket, - boolean legacyFormat + byte[] rawPacket ) { this.toLogin = toLogin; this.fromLogin = fromLogin; @@ -50,85 +56,27 @@ final class SignedMessageBlock { this.nonce = nonce; this.messageType = messageType; this.revisionTimeMs = revisionTimeMs; + this.reencryptedAtMs = reencryptedAtMs; this.formatVersionMajor = formatVersionMajor; this.formatVersionMinor = formatVersionMinor; - this.payloadBytes = payloadBytes; - this.encryptedBodyBytes = encryptedBodyBytes; + this.bodyBytes = bodyBytes; + this.encryptedBody = encryptedBody; + this.readReceiptBody = readReceiptBody; this.signedBody = signedBody; this.signature64 = signature64; this.rawPacket = rawPacket; - this.legacyFormat = legacyFormat; } - static SignedMessageBlock parse(byte[] raw, int maxEncryptedBodyBytes) { - if (raw == null || raw.length < 64) { + static SignedMessageBlock parse(byte[] raw, int maxBodyBytes) { + if (raw == null || raw.length < PREFIX.length + 2 + 1 + 1 + 8 + 4 + 1 + 8 + 8 + 4 + 64) { throw new IllegalArgumentException("BAD_LEN"); } + if (!startsWith(raw, PREFIX)) { + throw new IllegalArgumentException("BAD_PREFIX"); + } - if (startsWith(raw, LEGACY_PREFIX)) { - return parseLegacy(raw, maxEncryptedBodyBytes); - } - if (startsWith(raw, V1_PREFIX)) { - return parseV1(raw, maxEncryptedBodyBytes); - } - throw new IllegalArgumentException("BAD_PREFIX"); - } - - private static SignedMessageBlock parseLegacy(byte[] raw, int maxPayloadBytes) { - if (raw.length < LEGACY_PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) { - throw new IllegalArgumentException("BAD_LEN"); - } ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN); - bb.position(LEGACY_PREFIX.length); - - String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN"); - String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN"); - - long timeMs = bb.getLong(); - if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME"); - - long nonce = Integer.toUnsignedLong(bb.getInt()); - int messageType = Short.toUnsignedInt(bb.getShort()); - ensureMessageType(messageType); - - int payloadLen = Short.toUnsignedInt(bb.getShort()); - if (payloadLen < 1 || payloadLen > maxPayloadBytes) { - throw new IllegalArgumentException("BAD_MESSAGE_LEN"); - } - if (bb.remaining() != payloadLen + 64) { - throw new IllegalArgumentException("BAD_LEN"); - } - - byte[] payload = new byte[payloadLen]; - bb.get(payload); - byte[] signature64 = new byte[64]; - bb.get(signature64); - byte[] signedBody = Arrays.copyOf(raw, raw.length - 64); - - return new SignedMessageBlock( - toLogin, - fromLogin, - timeMs, - nonce, - messageType, - 0L, - 2, - 0, - payload, - payload, - signedBody, - signature64, - raw, - true - ); - } - - private static SignedMessageBlock parseV1(byte[] raw, int maxEncryptedBodyBytes) { - if (raw.length < V1_PREFIX.length + 2 + 1 + 1 + 8 + 4 + 2 + 8 + 1 + 4 + 64) { - throw new IllegalArgumentException("BAD_LEN"); - } - ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN); - bb.position(V1_PREFIX.length); + bb.position(PREFIX.length); int major = Byte.toUnsignedInt(bb.get()); int minor = Byte.toUnsignedInt(bb.get()); @@ -141,32 +89,55 @@ final class SignedMessageBlock { long timeMs = bb.getLong(); if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME"); long nonce = Integer.toUnsignedLong(bb.getInt()); - int messageType = Short.toUnsignedInt(bb.getShort()); + int messageType = Byte.toUnsignedInt(bb.get()); ensureMessageType(messageType); long revisionTimeMs = bb.getLong(); if (revisionTimeMs < 0) throw new IllegalArgumentException("BAD_REVISION_TIME"); + long reencryptedAtMs = bb.getLong(); + if (reencryptedAtMs < 0) throw new IllegalArgumentException("BAD_REENCRYPTED_TIME"); - int attachmentsCount = Byte.toUnsignedInt(bb.get()); - if (attachmentsCount != 0) { - throw new IllegalArgumentException("ATTACHMENTS_DISABLED"); - } - - if (bb.remaining() < 4 + 64) { - throw new IllegalArgumentException("BAD_LEN"); - } - long encryptedBodyLen = Integer.toUnsignedLong(bb.getInt()); - if (encryptedBodyLen > maxEncryptedBodyBytes) { + long bodyLen = Integer.toUnsignedLong(bb.getInt()); + if (bodyLen > maxBodyBytes) { throw new IllegalArgumentException("BAD_MESSAGE_LEN"); } - if (bb.remaining() != encryptedBodyLen + 64) { + if (bb.remaining() != bodyLen + 64) { throw new IllegalArgumentException("BAD_LEN"); } - byte[] encryptedBody = new byte[(int) encryptedBodyLen]; - bb.get(encryptedBody); + + byte[] bodyBytes = new byte[(int) bodyLen]; + bb.get(bodyBytes); byte[] signature64 = new byte[64]; bb.get(signature64); byte[] signedBody = Arrays.copyOf(raw, raw.length - 64); + EncryptedBodyPayload encryptedBody = null; + ReadReceiptPayload readReceiptBody = null; + + 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) { + throw new IllegalArgumentException("BAD_RECEIPT_REVISION"); + } + readReceiptBody = ReadReceiptPayload.parse(bodyBytes); + } else if (messageType == TYPE_MESSAGE_DELETED_BY_SENDER || messageType == TYPE_MESSAGE_DELETED_BY_RECIPIENT) { + if (bodyBytes.length != 0) throw new IllegalArgumentException("BAD_DELETE_BODY"); + if (revisionTimeMs <= 0) throw new IllegalArgumentException("BAD_REVISION_TIME"); + if (reencryptedAtMs != 0) throw new IllegalArgumentException("BAD_REENCRYPTED_TIME"); + } else if (messageType == TYPE_CONVERSATION_DELETED_BY_SENDER || messageType == TYPE_CONVERSATION_DELETED_BY_RECIPIENT) { + if (bodyBytes.length != 0) throw new IllegalArgumentException("BAD_DELETE_BODY"); + if (revisionTimeMs != 0) throw new IllegalArgumentException("BAD_REVISION_TIME"); + if (reencryptedAtMs != 0) throw new IllegalArgumentException("BAD_REENCRYPTED_TIME"); + } + return new SignedMessageBlock( toLogin, fromLogin, @@ -174,23 +145,24 @@ final class SignedMessageBlock { nonce, messageType, revisionTimeMs, + reencryptedAtMs, major, minor, + bodyBytes, encryptedBody, - encryptedBody, + readReceiptBody, signedBody, signature64, - raw, - false + raw ); } - boolean isIncomingType() { - return messageType == TYPE_INCOMING_TEXT || messageType == TYPE_READ_INCOMING; + boolean isIncomingCopyType() { + return messageType == TYPE_INCOMING_TEXT; } boolean isOutgoingCopyType() { - return messageType == TYPE_OUTGOING_COPY || messageType == TYPE_READ_OUTGOING_COPY; + return messageType == TYPE_OUTGOING_COPY; } boolean isContentType() { @@ -201,16 +173,25 @@ final class SignedMessageBlock { return messageType == TYPE_READ_INCOMING || messageType == TYPE_READ_OUTGOING_COPY; } - boolean isDeletedContent() { - return isContentType() && !legacyFormat && encryptedBodyBytes.length == 0; + boolean isMessageDeleteType() { + return messageType == TYPE_MESSAGE_DELETED_BY_SENDER || messageType == TYPE_MESSAGE_DELETED_BY_RECIPIENT; } - String targetLogin() { - return isIncomingType() ? toLogin : fromLogin; + boolean isConversationDeleteType() { + return messageType == TYPE_CONVERSATION_DELETED_BY_SENDER || messageType == TYPE_CONVERSATION_DELETED_BY_RECIPIENT; + } + + boolean isDeleteType() { + return isMessageDeleteType() || isConversationDeleteType(); + } + + boolean isSignedByRecipient() { + return messageType == TYPE_MESSAGE_DELETED_BY_RECIPIENT + || messageType == TYPE_CONVERSATION_DELETED_BY_RECIPIENT; } private static void ensureMessageType(int messageType) { - if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_READ_OUTGOING_COPY) { + if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_CONVERSATION_DELETED_BY_RECIPIENT) { throw new IllegalArgumentException("BAD_MESSAGE_TYPE"); } } 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 0030ba7..acf5333 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 @@ -5,26 +5,24 @@ import shine.db.entities.SignedMessageV2Entry; import shine.db.entities.SolanaUserEntry; import utils.crypto.Ed25519Util; -import java.nio.charset.StandardCharsets; import java.util.Base64; final class SignedMessagesCore { - private static final int MAX_ENCRYPTED_BODY_BYTES = 16384; + private static final int MAX_BODY_BYTES = 16384; private SignedMessagesCore() {} static SignedMessageBlock parseFromB64(String blobB64) { try { byte[] raw = Base64.getDecoder().decode(blobB64.trim()); - return SignedMessageBlock.parse(raw, MAX_ENCRYPTED_BODY_BYTES); + return SignedMessageBlock.parse(raw, MAX_BODY_BYTES); } catch (IllegalArgumentException e) { String code = e.getMessage(); if (code == null || code.isBlank()) { throw new IllegalArgumentException("BAD_BLOCK_FORMAT"); } switch (code) { - case "ATTACHMENTS_DISABLED", - "BAD_PREFIX", + case "BAD_PREFIX", "BAD_LEN", "BAD_TO_LOGIN", "BAD_FROM_LOGIN", @@ -32,7 +30,16 @@ final class SignedMessagesCore { "BAD_MESSAGE_TYPE", "BAD_MESSAGE_LEN", "BAD_FORMAT_VERSION", - "BAD_REVISION_TIME" -> throw e; + "BAD_REVISION_TIME", + "BAD_REENCRYPTED_TIME", + "BAD_DELETE_BODY", + "BAD_RECEIPT_PAYLOAD_LEN", + "BAD_RECEIPT_TO_LOGIN", + "BAD_RECEIPT_FROM_LOGIN", + "BAD_RECEIPT_TIME", + "BAD_RECEIPT_REVISION", + "BAD_ENCRYPTED_BODY", + "BAD_CRYPTO_METHOD" -> throw e; default -> throw new IllegalArgumentException("BAD_BLOCK_FORMAT"); } } @@ -44,7 +51,10 @@ final class SignedMessagesCore { if (from == null || to == null) { throw new IllegalArgumentException("USER_NOT_FOUND"); } - byte[] pubKey32 = Ed25519Util.keyFromBase64(from.getClientKey()); + + String signerLogin = block.isSignedByRecipient() ? block.toLogin : block.fromLogin; + SolanaUserEntry signer = signerLogin.equalsIgnoreCase(block.fromLogin) ? from : to; + byte[] pubKey32 = Ed25519Util.keyFromBase64(signer.getClientKey()); if (!Ed25519Util.verify(block.signedBody, block.signature64, pubKey32)) { throw new IllegalArgumentException("BAD_SIGNATURE"); } @@ -57,38 +67,28 @@ final class SignedMessagesCore { if (!incoming.fromLogin.equalsIgnoreCase(outgoing.fromLogin)) throw new IllegalArgumentException("BAD_PAIR_KEYS"); if (incoming.timeMs != outgoing.timeMs) throw new IllegalArgumentException("BAD_PAIR_KEYS"); if (incoming.nonce != outgoing.nonce) throw new IllegalArgumentException("BAD_PAIR_KEYS"); - - if (incoming.isReadReceiptType()) { - ReadReceiptPayload inRef = ReadReceiptPayload.parse(incoming.payloadBytes); - ReadReceiptPayload outRef = ReadReceiptPayload.parse(outgoing.payloadBytes); - if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin) - || !inRef.refFromLogin.equalsIgnoreCase(outRef.refFromLogin) - || inRef.refTimeMs != outRef.refTimeMs - || inRef.refNonce != outRef.refNonce - || inRef.refType != outRef.refType) { - throw new IllegalArgumentException("BAD_RECEIPT_REF"); - } - return; - } - - if (incoming.legacyFormat || outgoing.legacyFormat) { - throw new IllegalArgumentException("BAD_CONTENT_FORMAT"); - } - if (incoming.revisionTimeMs != outgoing.revisionTimeMs) { - throw new IllegalArgumentException("BAD_REVISION_TIME"); - } + if (incoming.revisionTimeMs != outgoing.revisionTimeMs) throw new IllegalArgumentException("BAD_REVISION_TIME"); + if (incoming.reencryptedAtMs != outgoing.reencryptedAtMs) throw new IllegalArgumentException("BAD_REENCRYPTED_TIME"); if (incoming.formatVersionMajor != outgoing.formatVersionMajor || incoming.formatVersionMinor != outgoing.formatVersionMinor) { throw new IllegalArgumentException("BAD_FORMAT_VERSION"); } - if (incoming.encryptedBodyBytes.length != outgoing.encryptedBodyBytes.length) { - throw new IllegalArgumentException("BAD_MESSAGE_LEN"); - } - for (int i = 0; i < incoming.encryptedBodyBytes.length; i++) { - if (incoming.encryptedBodyBytes[i] != outgoing.encryptedBodyBytes[i]) { - throw new IllegalArgumentException("BAD_ENCRYPTED_BODY"); + if (incoming.isReadReceiptType()) { + if (!outgoing.isReadReceiptType()) throw new IllegalArgumentException("BAD_PAIR_TYPES"); + ReadReceiptPayload inRef = incoming.readReceiptBody; + ReadReceiptPayload outRef = outgoing.readReceiptBody; + if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin) + || !inRef.refFromLogin.equalsIgnoreCase(outRef.refFromLogin) + || inRef.refTimeMs != outRef.refTimeMs + || inRef.refNonce != outRef.refNonce) { + throw new IllegalArgumentException("BAD_RECEIPT_REF"); } + return; + } + + if (!incoming.isContentType() || !outgoing.isContentType()) { + throw new IllegalArgumentException("BAD_PAIR_TYPES"); } } @@ -99,32 +99,40 @@ final class SignedMessagesCore { SignedMessageV2Entry entry = new SignedMessageV2Entry(); entry.setMessageKey(messageKey); entry.setBaseKey(baseKey); - entry.setTargetLogin(block.targetLogin()); + entry.setTargetLogin(primaryTargetLogin(block)); entry.setFromLogin(block.fromLogin); entry.setToLogin(block.toLogin); entry.setTimeMs(block.timeMs); entry.setNonce(block.nonce); entry.setMessageType(block.messageType); entry.setRevisionTimeMs(block.revisionTimeMs); + entry.setReencryptedAtMs(block.reencryptedAtMs); entry.setRawBlock(block.rawPacket); entry.setCreatedAtMs(System.currentTimeMillis()); entry.setSourceApi(sourceApi); entry.setOriginSessionId(originSessionId); - if (block.messageType == SignedMessageBlock.TYPE_READ_INCOMING - || block.messageType == SignedMessageBlock.TYPE_READ_OUTGOING_COPY) { - ReadReceiptPayload ref = ReadReceiptPayload.parse(block.payloadBytes); + if (block.isReadReceiptType()) { + ReadReceiptPayload ref = block.readReceiptBody; entry.setReceiptRefBaseKey(ref.refBaseKey()); - entry.setReceiptRefType(ref.refType); } return entry; } static String previewTextForPush(SignedMessageBlock block) { - if (!block.isContentType() || block.encryptedBodyBytes == null || block.encryptedBodyBytes.length == 0) { - return ""; + return ""; + } + + private static String primaryTargetLogin(SignedMessageBlock block) { + if (block.messageType == SignedMessageBlock.TYPE_INCOMING_TEXT + || block.messageType == SignedMessageBlock.TYPE_READ_INCOMING) { + return block.toLogin; } - return new String(block.encryptedBodyBytes, StandardCharsets.UTF_8); + if (block.messageType == SignedMessageBlock.TYPE_OUTGOING_COPY + || block.messageType == SignedMessageBlock.TYPE_READ_OUTGOING_COPY) { + return block.fromLogin; + } + return block.toLogin; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java index ee344a4..67e187c 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java @@ -13,43 +13,46 @@ import shine.db.dao.SignedMessagesV2DAO; import shine.db.entities.ActiveSessionEntry; import shine.db.entities.SignedMessageV2Entry; +import java.util.ArrayList; import java.util.Base64; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; public final class SignedMessagesRealtime { private static final Logger log = LoggerFactory.getLogger(SignedMessagesRealtime.class); private static final ObjectMapper MAPPER = new ObjectMapper(); + private SignedMessagesRealtime() {} - static DeliveryCounters deliverToTargetSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception { - return deliverToTargetSessions(message, block, null); + static DeliveryCounters deliverToRelevantSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception { + return deliverToRelevantSessions(message, block, null); } - static DeliveryCounters deliverToTargetSessions( + static DeliveryCounters deliverToRelevantSessions( SignedMessageV2Entry message, SignedMessageBlock block, String excludeSessionId ) throws Exception { DeliveryCounters counters = new DeliveryCounters(); - List sessions = ActiveSessionsDAO.getInstance().getByLogin(message.getTargetLogin()); long now = System.currentTimeMillis(); - for (ActiveSessionEntry s : sessions) { - String sessionId = s.getSessionId(); - if (excludeSessionId != null && excludeSessionId.equals(sessionId)) { - continue; - } - SignedMessagesV2DAO.getInstance().ensureDeliveryRow(message.getMessageKey(), sessionId, now); - boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, message, false); - if (deliveredOnline) { - counters.wsDelivered++; - continue; - } - if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT - && block != null - && block.revisionTimeMs == 0 - && !block.isDeletedContent()) { - boolean pushed = pushNewMessageNotification(s, message, block); - if (pushed) counters.pushDelivered++; + for (String targetLogin : targetLoginsForMessage(message)) { + List sessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin); + for (ActiveSessionEntry s : sessions) { + String sessionId = s.getSessionId(); + if (excludeSessionId != null && excludeSessionId.equals(sessionId)) { + continue; + } + SignedMessagesV2DAO.getInstance().ensureDeliveryRow(message.getMessageKey(), sessionId, now); + boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, targetLogin, message, false); + if (deliveredOnline) { + counters.wsDelivered++; + continue; + } + if (shouldPushNewIncomingMessage(targetLogin, message, block)) { + boolean pushed = pushNewMessageNotification(s, message); + if (pushed) counters.pushDelivered++; + } } } return counters; @@ -65,14 +68,19 @@ public final class SignedMessagesRealtime { List pending = SignedMessagesV2DAO.getInstance() .listPendingForSession(login, sessionId); for (SignedMessageV2Entry e : pending) { - sendEventToSessionIfOnline(sessionId, e, true); + sendEventToSessionIfOnline(sessionId, login, e, true); } } catch (Exception e) { log.warn("Failed to dispatch pending messages for sessionId={}", sessionId, e); } } - private static boolean sendEventToSessionIfOnline(String sessionId, SignedMessageV2Entry message, boolean backlog) { + private static boolean sendEventToSessionIfOnline( + String sessionId, + String actualTargetLogin, + SignedMessageV2Entry message, + boolean backlog + ) { ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId); if (targetCtx == null) return false; @@ -82,7 +90,7 @@ public final class SignedMessagesRealtime { payload.put("baseKey", message.getBaseKey()); payload.put("fromLogin", message.getFromLogin()); payload.put("toLogin", message.getToLogin()); - payload.put("targetLogin", message.getTargetLogin()); + payload.put("targetLogin", actualTargetLogin); payload.put("messageType", message.getMessageType()); payload.put("timeMs", message.getTimeMs()); payload.put("nonce", message.getNonce()); @@ -91,27 +99,23 @@ public final class SignedMessagesRealtime { if (message.getReceiptRefBaseKey() != null) { payload.put("receiptRefBaseKey", message.getReceiptRefBaseKey()); } - if (message.getReceiptRefType() != null) { - payload.put("receiptRefType", message.getReceiptRefType()); - } return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload); } - private static boolean pushNewMessageNotification( - ActiveSessionEntry session, - SignedMessageV2Entry message, - SignedMessageBlock block - ) { + private static boolean shouldPushNewIncomingMessage(String targetLogin, SignedMessageV2Entry message, SignedMessageBlock block) { + if (block == null) return false; + if (message.getMessageType() != SignedMessageBlock.TYPE_INCOMING_TEXT) return false; + if (!targetLogin.equalsIgnoreCase(message.getToLogin())) return false; + return block.revisionTimeMs == 0; + } + + private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageV2Entry message) { try { if (session == null) return false; if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) { return false; } - String preview = SignedMessagesCore.previewTextForPush(block).replace('\n', ' ').trim(); - if (preview.length() > 80) preview = preview.substring(0, 80) + "..."; - String text = preview.isBlank() - ? "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения." - : preview; + String text = "Вам пришло новое личное сообщение от " + message.getFromLogin() + "."; String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}"; return WebPushSender.sendBase64Payload( session.getPushEndpoint(), @@ -124,6 +128,28 @@ public final class SignedMessagesRealtime { } } + private static List targetLoginsForMessage(SignedMessageV2Entry message) { + Set out = new LinkedHashSet<>(); + int type = message.getMessageType(); + if (type == SignedMessageBlock.TYPE_INCOMING_TEXT || type == SignedMessageBlock.TYPE_READ_INCOMING) { + out.add(safeLower(message.getToLogin())); + } else if (type == SignedMessageBlock.TYPE_OUTGOING_COPY || type == SignedMessageBlock.TYPE_READ_OUTGOING_COPY) { + out.add(safeLower(message.getFromLogin())); + } else if (type >= SignedMessageBlock.TYPE_MESSAGE_DELETED_BY_SENDER + && type <= SignedMessageBlock.TYPE_CONVERSATION_DELETED_BY_RECIPIENT) { + out.add(safeLower(message.getFromLogin())); + out.add(safeLower(message.getToLogin())); + } else if (!isBlank(message.getTargetLogin())) { + out.add(safeLower(message.getTargetLogin())); + } + out.remove(""); + return new ArrayList<>(out); + } + + private static String safeLower(String s) { + return s == null ? "" : s.trim().toLowerCase(); + } + private static String jsonEscape(String s) { if (s == null) return ""; StringBuilder out = new StringBuilder(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteConversation_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteConversation_Request.java new file mode 100644 index 0000000..efe87f4 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteConversation_Request.java @@ -0,0 +1,10 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_DeleteConversation_Request extends Net_Request { + private String blobB64; + + public String getBlobB64() { return blobB64; } + public void setBlobB64(String blobB64) { this.blobB64 = blobB64; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteConversation_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteConversation_Response.java new file mode 100644 index 0000000..634752b --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteConversation_Response.java @@ -0,0 +1,19 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_DeleteConversation_Response extends Net_Response { + private String messageKey; + private String baseKey; + private int deliveredWsSessions; + private int deliveredWebPushSessions; + + public String getMessageKey() { return messageKey; } + public void setMessageKey(String messageKey) { this.messageKey = messageKey; } + public String getBaseKey() { return baseKey; } + public void setBaseKey(String baseKey) { this.baseKey = baseKey; } + public int getDeliveredWsSessions() { return deliveredWsSessions; } + public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; } + public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; } + public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteMessage_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteMessage_Request.java new file mode 100644 index 0000000..4c51b5f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteMessage_Request.java @@ -0,0 +1,10 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_DeleteMessage_Request extends Net_Request { + private String blobB64; + + public String getBlobB64() { return blobB64; } + public void setBlobB64(String blobB64) { this.blobB64 = blobB64; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteMessage_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteMessage_Response.java new file mode 100644 index 0000000..fc0f0e9 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteMessage_Response.java @@ -0,0 +1,19 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_DeleteMessage_Response extends Net_Response { + private String messageKey; + private String baseKey; + private int deliveredWsSessions; + private int deliveredWebPushSessions; + + public String getMessageKey() { return messageKey; } + public void setMessageKey(String messageKey) { this.messageKey = messageKey; } + public String getBaseKey() { return baseKey; } + public void setBaseKey(String baseKey) { this.baseKey = baseKey; } + public int getDeliveredWsSessions() { return deliveredWsSessions; } + public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; } + public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; } + public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java new file mode 100644 index 0000000..5058975 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java @@ -0,0 +1,100 @@ +package server.sync; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService; +import utils.config.AppConfig; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class DmFederationService { + private static final Logger log = LoggerFactory.getLogger(DmFederationService.class); + private static final String CONFIG_KEY = "server.SHiNE.login"; + private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient(); + + private DmFederationService() {} + + public static void fanOutPair(String fromLogin, String toLogin, String incomingBlobB64, String outgoingBlobB64) { + try { + Map senderRoutes = + routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin)); + Map recipientRoutes = + routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin)); + + String ownServerLogin = ownServerLogin(); + for (SolanaUserPdaImportService.ParsedServerRoute route : senderRoutes.values()) { + if (isOwnServer(route, ownServerLogin)) continue; + REMOTE.sendMessagePair(route.serverAddress(), incomingBlobB64, outgoingBlobB64); + } + for (SolanaUserPdaImportService.ParsedServerRoute route : recipientRoutes.values()) { + if (isOwnServer(route, ownServerLogin)) continue; + if (senderRoutes.containsKey(route.login())) continue; + REMOTE.receiveIncomingMessage(route.serverAddress(), incomingBlobB64); + } + } catch (Exception e) { + log.warn("DM federation pair fan-out failed: from={} to={}", fromLogin, toLogin, e); + } + } + + public static void fanOutDeleteMessage(String fromLogin, String toLogin, int messageType, String blobB64) { + fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, true); + } + + public static void fanOutDeleteConversation(String fromLogin, String toLogin, int messageType, String blobB64) { + fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, false); + } + + private static void fanOutSingleDelete(String fromLogin, String toLogin, int messageType, String blobB64, boolean oneMessageDelete) { + try { + Map routes = routesByLogin( + SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin), + SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin) + ); + String ownServerLogin = ownServerLogin(); + for (SolanaUserPdaImportService.ParsedServerRoute route : routes.values()) { + if (isOwnServer(route, ownServerLogin)) continue; + if (oneMessageDelete) { + REMOTE.deleteMessage(route.serverAddress(), blobB64); + } else { + REMOTE.deleteConversation(route.serverAddress(), blobB64); + } + } + } catch (Exception e) { + log.warn("DM federation delete fan-out failed: from={} to={} type={}", + fromLogin, toLogin, messageType, e); + } + } + + @SafeVarargs + private static Map routesByLogin( + List... routeLists + ) { + Map out = new LinkedHashMap<>(); + for (List routeList : routeLists) { + for (SolanaUserPdaImportService.ParsedServerRoute route : routeList) { + if (route == null) continue; + String login = normalize(route.login()); + String address = route.serverAddress() == null ? "" : route.serverAddress().trim(); + if (login == null || address.isBlank()) continue; + out.putIfAbsent(login, route); + } + } + return out; + } + + private static boolean isOwnServer(SolanaUserPdaImportService.ParsedServerRoute route, String ownServerLogin) { + return ownServerLogin != null && ownServerLogin.equalsIgnoreCase(route.login()); + } + + private static String ownServerLogin() { + return normalize(AppConfig.getInstance().getParam(CONFIG_KEY)); + } + + private static String normalize(String value) { + if (value == null) return null; + String s = value.trim().toLowerCase(); + return s.isEmpty() ? null : s; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java new file mode 100644 index 0000000..c71d718 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java @@ -0,0 +1,177 @@ +package server.sync; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public final class RemoteDmSyncClient { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final HttpClient HTTP = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(6)) + .build(); + + public void sendMessagePair(String serverAddressRaw, String incomingBlobB64, String outgoingBlobB64) throws Exception { + String incomingJson = MAPPER.writeValueAsString(incomingBlobB64); + String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64); + JsonNode response = send(serverAddressRaw, """ + { + "op":"ReceiveOutcomingMessage", + "requestId":%s, + "payload":{ + "incomingBlobB64":%s, + "outgoingBlobB64":%s + } + } + """.formatted("%s", incomingJson, outgoingJson)); + ensureOk("ReceiveOutcomingMessage", response); + } + + public void receiveIncomingMessage(String serverAddressRaw, String incomingBlobB64) throws Exception { + String incomingJson = MAPPER.writeValueAsString(incomingBlobB64); + JsonNode response = send(serverAddressRaw, """ + { + "op":"ReceiveIncomingMessage", + "requestId":%s, + "payload":{ + "incomingBlobB64":%s + } + } + """.formatted("%s", incomingJson)); + ensureOk("ReceiveIncomingMessage", response); + } + + public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception { + String blobJson = MAPPER.writeValueAsString(blobB64); + JsonNode response = send(serverAddressRaw, """ + { + "op":"DeleteMessage", + "requestId":%s, + "payload":{ + "blobB64":%s + } + } + """.formatted("%s", blobJson)); + ensureOk("DeleteMessage", response); + } + + public void deleteConversation(String serverAddressRaw, String blobB64) throws Exception { + String blobJson = MAPPER.writeValueAsString(blobB64); + JsonNode response = send(serverAddressRaw, """ + { + "op":"DeleteConversation", + "requestId":%s, + "payload":{ + "blobB64":%s + } + } + """.formatted("%s", blobJson)); + ensureOk("DeleteConversation", response); + } + + private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception { + String requestId = MAPPER.writeValueAsString("dm-sync-" + UUID.randomUUID()); + String json = jsonTemplate.formatted(requestId); + String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw); + if (wsUrl == null) { + throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw); + } + + CompletableFuture responseFuture = new CompletableFuture<>(); + CountDownLatch openLatch = new CountDownLatch(1); + SyncWsListener listener = new SyncWsListener(responseFuture, openLatch); + + WebSocket webSocket = HTTP.newWebSocketBuilder() + .connectTimeout(Duration.ofSeconds(6)) + .buildAsync(URI.create(wsUrl), listener) + .get(8, TimeUnit.SECONDS); + + if (!openLatch.await(8, TimeUnit.SECONDS)) { + tryAbort(webSocket); + throw new TimeoutException("WS open timeout"); + } + + webSocket.sendText(json, true).get(8, TimeUnit.SECONDS); + String responseJson = responseFuture.get(12, TimeUnit.SECONDS); + tryAbort(webSocket); + return MAPPER.readTree(responseJson); + } + + private void ensureOk(String op, JsonNode response) { + int status = response.path("status").asInt(500); + if (status >= 200 && status < 300) return; + String code = response.path("code").asText(""); + if (code.isBlank()) code = response.path("error").asText(""); + throw new IllegalStateException(op + " failed: status=" + status + " code=" + code); + } + + private static void tryAbort(WebSocket webSocket) { + try { + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); + } catch (Exception ignored) { + } + try { + webSocket.abort(); + } catch (Exception ignored) { + } + } + + private static final class SyncWsListener implements WebSocket.Listener { + private final CompletableFuture responseFuture; + private final CountDownLatch openLatch; + private final StringBuilder textBuffer = new StringBuilder(); + + private SyncWsListener(CompletableFuture responseFuture, CountDownLatch openLatch) { + this.responseFuture = responseFuture; + this.openLatch = openLatch; + } + + @Override + public void onOpen(WebSocket webSocket) { + openLatch.countDown(); + webSocket.request(1); + } + + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + textBuffer.append(data); + if (last && !responseFuture.isDone()) { + responseFuture.complete(textBuffer.toString()); + } + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage onBinary(WebSocket webSocket, ByteBuffer data, boolean last) { + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + if (!responseFuture.isDone()) { + responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason)); + } + return CompletableFuture.completedFuture(null); + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + if (!responseFuture.isDone()) { + responseFuture.completeExceptionally(error); + } + openLatch.countDown(); + } + } +} diff --git a/VERSION.properties b/VERSION.properties index 566aa00..ec86dbb 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.297 -server.version=1.2.277 +client.version=1.2.298 +server.version=1.2.278 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 03c97c8..6d009bd 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -27,6 +27,8 @@ import { state, terminateCurrentSession, addSignedMessageToChat, + deleteConversationMessagesBefore, + deleteSignedMessageByBaseKey, markIncomingReadByBaseKey, markOutgoingReadByBaseKey, normalizeDmChatId, @@ -1018,10 +1020,29 @@ async function init() { const fromLogin = parsed.fromLogin || ''; const toLogin = parsed.toLogin || ''; const messageType = Number(parsed.messageType || 0); - const chatId = normalizeDmChatId(messageType === 2 ? toLogin : fromLogin); - const text = (messageType === 1 || messageType === 2) - ? String(parsed.text || '') - : ''; + const currentLogin = String(state.session?.login || '').trim().toLowerCase(); + const chatPeerLogin = currentLogin && currentLogin === String(fromLogin || '').trim().toLowerCase() + ? toLogin + : fromLogin; + const chatId = normalizeDmChatId(chatPeerLogin); + let text = ''; + if (messageType === 1 || messageType === 2) { + try { + const decrypted = await authService.decryptSignedMessageContent({ + parsed, + login: state.session?.login || '', + storagePwd: state.session?.storagePwdInMemory || '', + }); + text = String(decrypted?.text || ''); + } catch (error) { + addAppLogEntry({ + level: 'warn', + source: 'signed-dm', + message: 'Не удалось расшифровать DM', + details: { messageKey, baseKey: parsed.baseKey, error: error?.message || 'unknown' }, + }); + } + } let shouldRefreshToolbarUnread = false; @@ -1084,6 +1105,24 @@ async function init() { message: 'Получено подтверждение прочтения', details: { messageKey, baseKey: parsed.baseKey, messageType }, }); + } else if (messageType === 5 || messageType === 6) { + const changed = deleteSignedMessageByBaseKey(chatId, parsed.baseKey); + if (changed) shouldRefreshToolbarUnread = true; + addAppLogEntry({ + level: 'info', + source: 'signed-dm', + message: 'Получено удаление сообщения', + details: { messageKey, baseKey: parsed.baseKey, messageType, changed }, + }); + } else if (messageType === 7 || messageType === 8) { + const removed = deleteConversationMessagesBefore(chatId, Number(parsed.timeMs || 0)); + if (removed > 0) shouldRefreshToolbarUnread = true; + addAppLogEntry({ + level: 'info', + source: 'signed-dm', + message: 'Получено удаление истории переписки', + details: { messageKey, baseKey: parsed.baseKey, messageType, removed, boundaryTimeMs: Number(parsed.timeMs || 0) }, + }); } try { diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index b4636dd..1ff68d3 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -594,16 +594,25 @@ export function render({ navigate, route }) { } }; - const applyLocalRevision = ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => { + const applyLocalRevision = async ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => { if (!localOutgoingBlobB64) return; try { const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64); + let text = ''; + if (Number(parsed?.messageType || 0) === 1 || Number(parsed?.messageType || 0) === 2) { + const decrypted = await authService.decryptSignedMessageContent({ + parsed, + login: state.session.login, + storagePwd: state.session.storagePwdInMemory, + }); + text = String(decrypted?.text || ''); + } addSignedMessageToChat({ chatId, messageKey: fallbackMessageKey || parsed?.messageKey || '', baseKey: fallbackBaseKey || parsed?.baseKey || '', from: 'out', - text: parsed?.text || '', + text, messageType: Number(parsed?.messageType || 2), unread: false, rawBlobB64: localOutgoingBlobB64, @@ -625,11 +634,13 @@ export function render({ navigate, route }) { timeMs: base.timeMs, nonce: base.nonce, revisionTimeMs: Date.now(), + deleteByRecipient: msg?.from === 'in', }); - applyLocalRevision({ - localOutgoingBlobB64: result?.localOutgoingBlobB64 || '', - fallbackMessageKey: result?.outgoingKey || '', - fallbackBaseKey: result?.baseKey || result?.localBaseKey || '', + addSignedMessageToChat({ + chatId, + messageKey: String(msg?.messageKey || ''), + baseKey: String(msg?.baseKey || ''), + deleted: true, }); if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) { cancelEditMode({ restoreDraft: true }); @@ -670,7 +681,7 @@ export function render({ navigate, route }) { }); } - applyLocalRevision({ + void applyLocalRevision({ localOutgoingBlobB64: result?.localOutgoingBlobB64 || '', fallbackMessageKey: result?.outgoingKey || '', fallbackBaseKey: result?.baseKey || result?.localBaseKey || '', @@ -857,7 +868,6 @@ async function sendReadReceiptsForVisible(chatId) { refFromLogin: ref.fromLogin, refTimeMs: ref.timeMs, refNonce: ref.nonce, - refType: 1, }); if (row.baseKey) { markReadReceiptSentByBaseKey(row.baseKey); diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 7f451fa..d57344a 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -4,16 +4,26 @@ import { bytesToBase64, deriveEd25519FromMasterSecret, deriveMasterSecretFromPassword, + ed25519PublicToX25519Public, + ed25519SeedToX25519Private, exportEd25519PublicKeyB64, exportPkcs8B64, generateEd25519Pair, + extractEd25519SeedFromPkcs8B64, + hkdfSha256, importPkcs8Ed25519, publicKeyB64FromPkcs8Ed25519, randomBase64, + randomBytes, sha256Bytes, signBytes, signBase64, utf8Bytes, + x25519PublicFromPrivate, + x25519RandomPrivateKey, + x25519SharedSecret, + encryptBytesAesGcm, + decryptBytesAesGcm, } from './crypto-utils.js'; import { channelNameErrorText, @@ -215,14 +225,20 @@ function uint8Bytes(value) { } const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM'); -const DM2_PREFIX = utf8Bytes('SHiNE_dm2'); -const DM2_TYPE_INCOMING = 1; -const DM2_TYPE_OUTGOING_COPY = 2; -const DM2_TYPE_READ_INCOMING = 3; -const DM2_TYPE_READ_OUTGOING_COPY = 4; +const DM_TYPE_INCOMING = 1; +const DM_TYPE_OUTGOING_COPY = 2; +const DM_TYPE_READ_INCOMING = 3; +const DM_TYPE_READ_OUTGOING_COPY = 4; +const DM_TYPE_MESSAGE_DELETED_BY_SENDER = 5; +const DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT = 6; +const DM_TYPE_CONVERSATION_DELETED_BY_SENDER = 7; +const DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT = 8; const DM_FORMAT_VERSION_MAJOR = 1; const DM_FORMAT_VERSION_MINOR = 0; 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'); function ensureAsciiBytes(value, field, min = 1, max = 60) { const text = String(value || '').trim(); @@ -237,15 +253,15 @@ function ensureAsciiBytes(value, field, min = 1, max = 60) { return bytes; } -function dm2BaseKey({ toLogin, fromLogin, timeMs, nonce }) { +function dmBaseKey({ toLogin, fromLogin, timeMs, nonce }) { return `${fromLogin}|${toLogin}|${Number(timeMs)}|${Number(nonce)}`; } -function dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }) { - return `${dm2BaseKey({ toLogin, fromLogin, timeMs, nonce })}|${Number(messageType)}`; +function dmMessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }) { + return `${dmBaseKey({ toLogin, fromLogin, timeMs, nonce })}|${Number(messageType)}`; } -function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType }) { +function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce }) { const toBytes = ensureAsciiBytes(refToLogin, 'receipt.refToLogin'); const fromBytes = ensureAsciiBytes(refFromLogin, 'receipt.refFromLogin'); return concatBytes( @@ -253,10 +269,67 @@ function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, ref uint8Bytes(fromBytes.length), fromBytes, uint64Bytes(refTimeMs), uint32Bytes(refNonce), - uint16Bytes(refType), ); } +function buildDmEncryptedBodyBytes({ plainBytes, recipientClientKeyB64 }) { + return buildDmEncryptedBodyBytesAsync({ plainBytes, recipientClientKeyB64 }); +} + +async function buildDmEncryptedBodyBytesAsync({ plainBytes, recipientClientKeyB64 }) { + const recipientEd25519Pub = base64ToBytes(String(recipientClientKeyB64 || '').trim()); + if (recipientEd25519Pub.length !== 32) { + throw new Error('Некорректный clientKey получателя'); + } + const recipientX25519Pub = ed25519PublicToX25519Public(recipientEd25519Pub); + const ephemeralPriv = x25519RandomPrivateKey(); + const ephemeralPub = x25519PublicFromPrivate(ephemeralPriv); + const sharedSecret = x25519SharedSecret(ephemeralPriv, recipientX25519Pub); + const salt = concatBytes(ephemeralPub, recipientX25519Pub); + const aesKeyBytes = await hkdfSha256(sharedSecret, salt, DM_HKDF_INFO, 32); + const iv = randomBytes(12); + const cipherText = await encryptBytesAesGcm(plainBytes, aesKeyBytes, iv); + return concatBytes( + uint8Bytes(DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM), + uint8Bytes(DM_CRYPTO_VERSION_1_0), + uint8Bytes(ephemeralPub.length), + ephemeralPub, + uint8Bytes(iv.length), + iv, + uint32Bytes(cipherText.length), + cipherText, + ); +} + +function parseEncryptedDmBodyBytes(payloadBytes) { + if (!(payloadBytes instanceof Uint8Array)) throw new Error('BAD_ENCRYPTED_BODY'); + let o = 0; + const read = (n) => { + if (o + n > payloadBytes.length) throw new Error('BAD_ENCRYPTED_BODY'); + const out = payloadBytes.slice(o, o + n); + o += n; + return out; + }; + const readU8 = () => read(1)[0]; + const cryptoMethod = readU8(); + const cryptoVersion = readU8(); + const ephemeralPubKeyLen = readU8(); + const ephemeralPubKey = read(ephemeralPubKeyLen); + const ivLen = readU8(); + const iv = read(ivLen); + const cipherTextLenBytes = read(4); + const cipherTextLen = new DataView(cipherTextLenBytes.buffer, cipherTextLenBytes.byteOffset, 4).getUint32(0, false); + const cipherText = read(cipherTextLen); + if (o !== payloadBytes.length) throw new Error('BAD_ENCRYPTED_BODY'); + return { + cryptoMethod, + cryptoVersion, + ephemeralPubKey, + iv, + cipherText, + }; +} + function parseSignedMessageBlockBytes(bytes) { if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array'); let o = 0; @@ -267,11 +340,6 @@ function parseSignedMessageBlockBytes(bytes) { return out; }; const readU8 = () => read(1)[0]; - const readU16 = () => { - const part = read(2); - const view = new DataView(part.buffer, part.byteOffset, 2); - return view.getUint16(0, false); - }; const readU32 = () => { const part = read(4); const view = new DataView(part.buffer, part.byteOffset, 4); @@ -301,87 +369,59 @@ function parseSignedMessageBlockBytes(bytes) { return true; }; - if (startsWith(DM_PREFIX_V1)) { - read(DM_PREFIX_V1.length); - const formatVersionMajor = readU8(); - const formatVersionMinor = readU8(); - const toLogin = readAscii(); - const fromLogin = readAscii(); - const timeMs = readU64(); - const nonce = readU32(); - const messageType = readU16(); - const revisionTimeMs = readU64(); - const attachmentsCount = readU8(); - if (attachmentsCount !== 0) throw new Error('ATTACHMENTS_DISABLED'); - const encryptedBodyLen = readU32(); - const encryptedBodyBytes = read(encryptedBodyLen); - const signatureBytes = read(64); - if (o !== bytes.length) throw new Error('BAD_LEN'); - const signedBody = bytes.slice(0, bytes.length - 64); - const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce }); - const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }); - const bodyText = new TextDecoder().decode(encryptedBodyBytes); - return { - toLogin, - fromLogin, - timeMs, - nonce, - messageType, - revisionTimeMs, - formatVersionMajor, - formatVersionMinor, - encryptedBodyBytes, - encryptedBodyText: bodyText, - text: bodyText, - bodyAttachments: [], - payloadBytes: encryptedBodyBytes, - signatureBytes, - signedBody, - rawBytes: bytes, - baseKey, - messageKey, - legacyFormat: false, - deleted: attachmentsCount === 0 && encryptedBodyLen === 0, - }; - } - - const prefix = read(DM2_PREFIX.length); - for (let i = 0; i < DM2_PREFIX.length; i += 1) { - if (prefix[i] !== DM2_PREFIX[i]) throw new Error('BAD_PREFIX'); - } + if (!startsWith(DM_PREFIX_V1)) throw new Error('BAD_PREFIX'); + read(DM_PREFIX_V1.length); + const formatVersionMajor = readU8(); + const formatVersionMinor = readU8(); const toLogin = readAscii(); const fromLogin = readAscii(); const timeMs = readU64(); const nonce = readU32(); - const messageType = readU16(); - const payloadLen = readU16(); - const payloadBytes = read(payloadLen); + const messageType = readU8(); + const revisionTimeMs = readU64(); + const reencryptedAtMs = readU64(); + const bodyLen = readU32(); + const bodyBytes = read(bodyLen); const signatureBytes = read(64); if (o !== bytes.length) throw new Error('BAD_LEN'); const signedBody = bytes.slice(0, bytes.length - 64); - const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce }); - const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }); + const baseKey = dmBaseKey({ toLogin, fromLogin, timeMs, nonce }); + const messageKey = dmMessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }); + let encryptedBodyPayload = null; + let readReceiptPayload = null; + if (messageType === DM_TYPE_INCOMING || messageType === DM_TYPE_OUTGOING_COPY) { + encryptedBodyPayload = parseEncryptedDmBodyBytes(bodyBytes); + } else if (messageType === DM_TYPE_READ_INCOMING || messageType === DM_TYPE_READ_OUTGOING_COPY) { + readReceiptPayload = null; + } + return { toLogin, fromLogin, timeMs, nonce, messageType, - revisionTimeMs: 0, - encryptedBodyBytes: payloadBytes, - encryptedBodyText: new TextDecoder().decode(payloadBytes), - text: new TextDecoder().decode(payloadBytes), + revisionTimeMs, + reencryptedAtMs, + formatVersionMajor, + formatVersionMinor, + encryptedBodyBytes: bodyBytes, + encryptedBodyPayload, + text: '', bodyAttachments: [], - payloadBytes, + payloadBytes: bodyBytes, signatureBytes, signedBody, rawBytes: bytes, baseKey, messageKey, - legacyFormat: true, - deleted: false, + legacyFormat: false, + deleted: messageType >= DM_TYPE_MESSAGE_DELETED_BY_SENDER, + isMessageDelete: messageType === DM_TYPE_MESSAGE_DELETED_BY_SENDER || messageType === DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT, + isConversationDelete: messageType === DM_TYPE_CONVERSATION_DELETED_BY_SENDER || messageType === DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT, + readReceiptPayload, }; } @@ -2186,64 +2226,28 @@ export class AuthService { return response.payload || {}; } - async buildSignedDm2Block({ - login, - toLogin, - storagePwd, - timeMs, - nonce, - messageType, - payloadBytes, - }) { - const cleanFromLogin = String(login || '').trim(); - const cleanToLogin = String(toLogin || '').trim(); - if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin'); - if (!storagePwd) throw new Error('Не передан storagePwd для подписи'); - - const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd); - const clientPriv = secrets?.clientKey || secrets?.clientKey; - if (!clientPriv) throw new Error('Не найден приватный clientKey'); - const privateKey = await importPkcs8Ed25519(clientPriv); - - const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin'); - const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin'); - if (!(payloadBytes instanceof Uint8Array) || payloadBytes.length < 1 || payloadBytes.length > 4096) { - throw new Error('payload должен быть 1..4096 байт'); - } - - const preimage = concatBytes( - DM2_PREFIX, - uint8Bytes(toBytes.length), toBytes, - uint8Bytes(fromBytes.length), fromBytes, - uint64Bytes(timeMs), - uint32Bytes(nonce), - uint16Bytes(messageType), - uint16Bytes(payloadBytes.length), - payloadBytes, - ); - const signature = await signBytes(privateKey, preimage); - return concatBytes(preimage, signature); - } - - async buildSignedDmV1Block({ - login, + async buildSignedDmBlock({ + signerLogin, + fromLogin, toLogin, storagePwd, timeMs, nonce, messageType, revisionTimeMs = 0, - encryptedBodyBytes = new Uint8Array(0), + reencryptedAtMs = 0, + bodyBytes = new Uint8Array(0), }) { - const cleanFromLogin = String(login || '').trim(); + const cleanSignerLogin = String(signerLogin || '').trim(); + const cleanFromLogin = String(fromLogin || '').trim(); const cleanToLogin = String(toLogin || '').trim(); - if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin'); + if (!cleanSignerLogin || !cleanFromLogin || !cleanToLogin) throw new Error('Не передан signerLogin/fromLogin/toLogin'); if (!storagePwd) throw new Error('Не передан storagePwd для подписи'); - if (!(encryptedBodyBytes instanceof Uint8Array) || encryptedBodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) { - throw new Error(`encryptedBody должен быть 0..${DM_MAX_ENCRYPTED_BODY_BYTES} байт`); + if (!(bodyBytes instanceof Uint8Array) || bodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) { + throw new Error(`body должен быть 0..${DM_MAX_ENCRYPTED_BODY_BYTES} байт`); } - const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd); + const secrets = await loadEncryptedUserSecrets(cleanSignerLogin, storagePwd); const clientPriv = secrets?.clientKey || secrets?.clientKey; if (!clientPriv) throw new Error('Не найден приватный clientKey'); const privateKey = await importPkcs8Ed25519(clientPriv); @@ -2258,11 +2262,11 @@ export class AuthService { uint8Bytes(fromBytes.length), fromBytes, uint64Bytes(timeMs), uint32Bytes(nonce), - uint16Bytes(messageType), + uint8Bytes(messageType), uint64Bytes(revisionTimeMs), - uint8Bytes(0), - uint32Bytes(encryptedBodyBytes.length), - encryptedBodyBytes, + uint64Bytes(reencryptedAtMs), + uint32Bytes(bodyBytes.length), + bodyBytes, ); const signature = await signBytes(privateKey, preimage); return concatBytes(preimage, signature); @@ -2283,10 +2287,6 @@ export class AuthService { return out; }; const readU8 = () => read(1)[0]; - const readU16 = () => { - const part = read(2); - return new DataView(part.buffer, part.byteOffset, 2).getUint16(0, false); - }; const readU32 = () => { const part = read(4); return new DataView(part.buffer, part.byteOffset, 4).getUint32(0, false); @@ -2304,9 +2304,40 @@ export class AuthService { const refFromLogin = readAscii(); const refTimeMs = readU64(); const refNonce = readU32(); - const refType = readU16(); if (o !== payloadBytes.length) throw new Error('BAD_RECEIPT_LEN'); - return { refToLogin, refFromLogin, refTimeMs, refNonce, refType }; + return { refToLogin, refFromLogin, refTimeMs, refNonce }; + } + + async decryptSignedMessageContent({ parsed, blobB64 = '', login, storagePwd }) { + const parsedBlock = parsed || this.parseSignedMessageBlob(blobB64); + const messageType = Number(parsedBlock?.messageType || 0); + if (messageType !== DM_TYPE_INCOMING && messageType !== DM_TYPE_OUTGOING_COPY) { + return { text: '', plainBytes: new Uint8Array(0) }; + } + if (!storagePwd) throw new Error('Не передан storagePwd для расшифровки'); + const cleanLogin = String(login || '').trim(); + if (!cleanLogin) throw new Error('Не передан login для расшифровки'); + + const payload = parsedBlock.encryptedBodyPayload || parseEncryptedDmBodyBytes(parsedBlock.payloadBytes); + if (payload.cryptoMethod !== DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM || payload.cryptoVersion !== DM_CRYPTO_VERSION_1_0) { + throw new Error('Неподдерживаемый метод шифрования DM'); + } + + const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd); + const clientPrivPkcs8 = String(secrets?.clientKey || '').trim(); + if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey'); + const recipientSeed = extractEd25519SeedFromPkcs8B64(clientPrivPkcs8); + const recipientX25519Priv = ed25519SeedToX25519Private(recipientSeed); + const recipientEd25519PubB64 = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8); + const recipientX25519Pub = ed25519PublicToX25519Public(base64ToBytes(recipientEd25519PubB64)); + const sharedSecret = x25519SharedSecret(recipientX25519Priv, payload.ephemeralPubKey); + const salt = concatBytes(payload.ephemeralPubKey, recipientX25519Pub); + const aesKeyBytes = await hkdfSha256(sharedSecret, salt, DM_HKDF_INFO, 32); + const plainBytes = await decryptBytesAesGcm(payload.cipherText, aesKeyBytes, payload.iv); + return { + text: new TextDecoder().decode(plainBytes), + plainBytes, + }; } async sendMessagePair({ incomingBlobB64, outgoingBlobB64 }) { @@ -2330,6 +2361,7 @@ export class AuthService { timeMs, nonce, revisionTimeMs = 0, + reencryptedAtMs = 0, }) { const cleanFromLogin = String(login || '').trim(); const cleanToLogin = String(toLogin || '').trim(); @@ -2338,30 +2370,54 @@ export class AuthService { const normalizedTimeMs = Number(timeMs); const normalizedNonce = Number(nonce); const normalizedRevisionTimeMs = Number(revisionTimeMs || 0); + const normalizedReencryptedAtMs = Number(reencryptedAtMs || 0); if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs'); if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce'); if (!Number.isFinite(normalizedRevisionTimeMs) || normalizedRevisionTimeMs < 0) throw new Error('Некорректный revisionTimeMs'); - const encryptedBodyBytes = utf8Bytes(cleanText); + if (!Number.isFinite(normalizedReencryptedAtMs) || normalizedReencryptedAtMs < 0) throw new Error('Некорректный reencryptedAtMs'); - const incomingBlock = await this.buildSignedDmV1Block({ - login: cleanFromLogin, - toLogin: cleanToLogin, - storagePwd, - timeMs: normalizedTimeMs, - nonce: normalizedNonce, - messageType: DM2_TYPE_INCOMING, - revisionTimeMs: normalizedRevisionTimeMs, - encryptedBodyBytes, + const plainBytes = utf8Bytes(cleanText); + const targetUser = await this.getUser(cleanToLogin); + if (!targetUser?.exists || !String(targetUser?.clientKey || '').trim()) { + throw new Error('Не найден clientKey получателя'); + } + const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd); + const senderPrivPkcs8 = String(secrets?.clientKey || '').trim(); + if (!senderPrivPkcs8) throw new Error('Не найден приватный clientKey отправителя'); + const senderPublicKeyB64 = await publicKeyB64FromPkcs8Ed25519(senderPrivPkcs8); + + const incomingEncryptedBodyBytes = await buildDmEncryptedBodyBytes({ + plainBytes, + recipientClientKeyB64: String(targetUser.clientKey || '').trim(), }); - const outgoingBlock = await this.buildSignedDmV1Block({ - login: cleanFromLogin, + const outgoingEncryptedBodyBytes = await buildDmEncryptedBodyBytes({ + plainBytes, + recipientClientKeyB64: senderPublicKeyB64, + }); + + const incomingBlock = await this.buildSignedDmBlock({ + signerLogin: cleanFromLogin, + fromLogin: cleanFromLogin, toLogin: cleanToLogin, storagePwd, timeMs: normalizedTimeMs, nonce: normalizedNonce, - messageType: DM2_TYPE_OUTGOING_COPY, + messageType: DM_TYPE_INCOMING, revisionTimeMs: normalizedRevisionTimeMs, - encryptedBodyBytes, + reencryptedAtMs: normalizedReencryptedAtMs, + bodyBytes: incomingEncryptedBodyBytes, + }); + const outgoingBlock = await this.buildSignedDmBlock({ + signerLogin: cleanFromLogin, + fromLogin: cleanFromLogin, + toLogin: cleanToLogin, + storagePwd, + timeMs: normalizedTimeMs, + nonce: normalizedNonce, + messageType: DM_TYPE_OUTGOING_COPY, + revisionTimeMs: normalizedRevisionTimeMs, + reencryptedAtMs: normalizedReencryptedAtMs, + bodyBytes: outgoingEncryptedBodyBytes, }); const payload = await this.sendMessagePair({ @@ -2372,7 +2428,7 @@ export class AuthService { ...payload, localIncomingBlobB64: bytesToBase64(incomingBlock), localOutgoingBlobB64: bytesToBase64(outgoingBlock), - localBaseKey: dm2BaseKey({ toLogin: cleanToLogin, fromLogin: cleanFromLogin, timeMs: normalizedTimeMs, nonce: normalizedNonce }), + localBaseKey: dmBaseKey({ toLogin: cleanToLogin, fromLogin: cleanFromLogin, timeMs: normalizedTimeMs, nonce: normalizedNonce }), }; } @@ -2390,40 +2446,62 @@ export class AuthService { }); } - async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs }) { - return this.sendDirectMessageRevision({ - login, - toLogin, - text: '', + async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs, deleteByRecipient = false }) { + const cleanLogin = String(login || '').trim(); + const cleanPeerLogin = String(toLogin || '').trim(); + if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin'); + const normalizedTimeMs = Number(timeMs); + const normalizedNonce = Number(nonce); + const normalizedRevisionTimeMs = Number(revisionTimeMs || 0); + if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs'); + if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce'); + if (!Number.isFinite(normalizedRevisionTimeMs) || normalizedRevisionTimeMs <= 0) throw new Error('Некорректный revisionTimeMs'); + + const block = await this.buildSignedDmBlock({ + signerLogin: cleanLogin, + fromLogin: deleteByRecipient ? cleanPeerLogin : cleanLogin, + toLogin: deleteByRecipient ? cleanLogin : cleanPeerLogin, storagePwd, - timeMs, - nonce, - revisionTimeMs, + timeMs: normalizedTimeMs, + nonce: normalizedNonce, + messageType: deleteByRecipient ? DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT : DM_TYPE_MESSAGE_DELETED_BY_SENDER, + revisionTimeMs: normalizedRevisionTimeMs, + reencryptedAtMs: 0, + bodyBytes: new Uint8Array(0), }); + const blobB64 = bytesToBase64(block); + const response = await this.ws.request('DeleteMessage', { blobB64 }); + if (response.status !== 200) throw opError('DeleteMessage', response); + return { + ...(response.payload || {}), + localBlobB64: blobB64, + }; } - async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce, refType = DM2_TYPE_INCOMING }) { + async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce }) { const timeMs = Date.now(); const nonce = Math.floor(Math.random() * 0x100000000); - const payload = buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType }); + const payload = buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce }); - const type3 = await this.buildSignedDm2Block({ - login, + const type3 = await this.buildSignedDmBlock({ + signerLogin: login, + fromLogin: login, toLogin, storagePwd, timeMs, nonce, - messageType: DM2_TYPE_READ_INCOMING, - payloadBytes: payload, + messageType: DM_TYPE_READ_INCOMING, + bodyBytes: payload, }); - const type4 = await this.buildSignedDm2Block({ - login, + const type4 = await this.buildSignedDmBlock({ + signerLogin: login, + fromLogin: login, toLogin, storagePwd, timeMs, nonce, - messageType: DM2_TYPE_READ_OUTGOING_COPY, - payloadBytes: payload, + messageType: DM_TYPE_READ_OUTGOING_COPY, + bodyBytes: payload, }); return this.sendMessagePair({ incomingBlobB64: bytesToBase64(type3), @@ -2431,6 +2509,36 @@ export class AuthService { }); } + async deleteConversation({ login, toLogin, storagePwd, deleteByRecipient = false, timeMs = Date.now(), nonce = Math.floor(Math.random() * 0x100000000) }) { + const cleanLogin = String(login || '').trim(); + const cleanPeerLogin = String(toLogin || '').trim(); + if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin'); + const normalizedTimeMs = Number(timeMs); + const normalizedNonce = Number(nonce); + if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs'); + if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce'); + + const block = await this.buildSignedDmBlock({ + signerLogin: cleanLogin, + fromLogin: deleteByRecipient ? cleanPeerLogin : cleanLogin, + toLogin: deleteByRecipient ? cleanLogin : cleanPeerLogin, + storagePwd, + timeMs: normalizedTimeMs, + nonce: normalizedNonce, + messageType: deleteByRecipient ? DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT : DM_TYPE_CONVERSATION_DELETED_BY_SENDER, + revisionTimeMs: 0, + reencryptedAtMs: 0, + bodyBytes: new Uint8Array(0), + }); + const blobB64 = bytesToBase64(block); + const response = await this.ws.request('DeleteConversation', { blobB64 }); + if (response.status !== 200) throw opError('DeleteConversation', response); + return { + ...(response.payload || {}), + localBlobB64: blobB64, + }; + } + async ackSessionDelivery(messageKey) { const response = await this.ws.request('AckSessionDelivery', { messageKey }); if (response.status !== 200) throw opError('AckSessionDelivery', response); diff --git a/shine-UI/js/services/crypto-utils.js b/shine-UI/js/services/crypto-utils.js index 1d1b6d6..b5bb051 100644 --- a/shine-UI/js/services/crypto-utils.js +++ b/shine-UI/js/services/crypto-utils.js @@ -1,6 +1,7 @@ const encoder = new TextEncoder(); const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.'; import { argon2idAsync } from 'https://esm.sh/@noble/hashes@1.8.0/argon2.js'; +import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519 } from 'https://esm.sh/@noble/curves@1.8.1/ed25519'; const SHINE_KEY_DERIVATION_PREFIX = 'SHiNE-key'; function getCryptoApi() { @@ -340,3 +341,56 @@ export async function signBytes(privateKey, bytes) { const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, bytes); return new Uint8Array(signature); } + +export function extractEd25519SeedFromPkcs8B64(pkcs8B64) { + const bytes = base64ToBytes(String(pkcs8B64 || '').trim()); + if (bytes.length < 32) throw new Error('Некорректный PKCS8 ключ client.key'); + return bytes.slice(bytes.length - 32); +} + +export function ed25519SeedToX25519Private(seed32) { + const seed = seed32 instanceof Uint8Array ? seed32 : new Uint8Array(seed32 || []); + if (seed.length !== 32) throw new Error('Seed Ed25519 должен быть 32 байта'); + return new Uint8Array(edwardsToMontgomeryPriv(seed)); +} + +export function ed25519PublicToX25519Public(ed25519Pub32) { + const pub = ed25519Pub32 instanceof Uint8Array ? ed25519Pub32 : new Uint8Array(ed25519Pub32 || []); + if (pub.length !== 32) throw new Error('Публичный Ed25519 ключ должен быть 32 байта'); + return new Uint8Array(edwardsToMontgomeryPub(pub)); +} + +export function x25519RandomPrivateKey() { + return new Uint8Array(x25519.utils.randomPrivateKey()); +} + +export function x25519PublicFromPrivate(privateKey32) { + const key = privateKey32 instanceof Uint8Array ? privateKey32 : new Uint8Array(privateKey32 || []); + if (key.length !== 32) throw new Error('Приватный X25519 ключ должен быть 32 байта'); + return new Uint8Array(x25519.getPublicKey(key)); +} + +export function x25519SharedSecret(privateKey32, publicKey32) { + const priv = privateKey32 instanceof Uint8Array ? privateKey32 : new Uint8Array(privateKey32 || []); + const pub = publicKey32 instanceof Uint8Array ? publicKey32 : new Uint8Array(publicKey32 || []); + if (priv.length !== 32 || pub.length !== 32) { + throw new Error('X25519 ключи должны быть по 32 байта'); + } + return new Uint8Array(x25519.getSharedSecret(priv, pub)); +} + +export async function hkdfSha256(ikmBytes, saltBytes, infoBytes, outLen) { + const subtle = getSubtleApi(); + const baseKey = await subtle.importKey('raw', ikmBytes, 'HKDF', false, ['deriveBits']); + const bits = await subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: saltBytes instanceof Uint8Array ? saltBytes : new Uint8Array(saltBytes || []), + info: infoBytes instanceof Uint8Array ? infoBytes : new Uint8Array(infoBytes || []), + }, + baseKey, + Number(outLen) * 8, + ); + return new Uint8Array(bits); +} diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index b082b11..999a90f 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -660,6 +660,45 @@ export function addSignedMessageToChat({ return true; } +export function deleteSignedMessageByBaseKey(chatId, baseKey) { + const normalizedChatId = normalizeDmChatId(chatId); + const normalizedBaseKey = String(baseKey || '').trim(); + if (!normalizedChatId || !normalizedBaseKey) return false; + const list = getChatMessages(normalizedChatId); + let changed = false; + for (let i = list.length - 1; i >= 0; i -= 1) { + const row = list[i]; + if (String(row?.baseKey || '').trim() !== normalizedBaseKey) continue; + changed = true; + removeStoredMessageRecord(String(row?.messageKey || '').trim()); + list.splice(i, 1); + } + if (changed) { + sortChatMessagesInPlace(normalizedChatId); + } + return changed; +} + +export function deleteConversationMessagesBefore(chatId, boundaryTimeMs) { + const normalizedChatId = normalizeDmChatId(chatId); + const boundary = Number(boundaryTimeMs || 0); + if (!normalizedChatId || !Number.isFinite(boundary) || boundary <= 0) return 0; + const list = getChatMessages(normalizedChatId); + let removed = 0; + for (let i = list.length - 1; i >= 0; i -= 1) { + const row = list[i]; + const rowTime = resolveChatMessageTimeMs(row); + if (!Number.isFinite(rowTime) || rowTime >= boundary) continue; + removed += 1; + removeStoredMessageRecord(String(row?.messageKey || '').trim()); + list.splice(i, 1); + } + if (removed > 0) { + sortChatMessagesInPlace(normalizedChatId); + } + return removed; +} + export function markChatRead(chatId) { const normalizedChatId = normalizeDmChatId(chatId); const list = getChatMessages(normalizedChatId);