SHA256
Реализовать SHiNE_DM v1 с E2EE и tombstone
This commit is contained in:
@@ -61,6 +61,8 @@
|
|||||||
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
|
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
|
||||||
| `ReceiveOutcomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | алиас `SendMessagePair` |
|
| `ReceiveOutcomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | алиас `SendMessagePair` |
|
||||||
| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока |
|
| `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` | подтверждение доставки в сессию |
|
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
|
||||||
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
|
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
|
||||||
| `CallSignalToSession` | `12_Direct_Messages_Push_Calls_API.md` | сигнал звонка в конкретную сессию |
|
| `CallSignalToSession` | `12_Direct_Messages_Push_Calls_API.md` | сигнал звонка в конкретную сессию |
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
- `incomingBlobB64` — блок `type=1` или `type=3`
|
- `incomingBlobB64` — блок `type=1` или `type=3`
|
||||||
- `outgoingBlobB64` — блок `type=2` или `type=4`
|
- `outgoingBlobB64` — блок `type=2` или `type=4`
|
||||||
|
|
||||||
Для контентных сообщений `type=1/2` внутри base64 лежит бинарный формат `SHiNE_DM`.
|
Все типы в этой паре используют бинарный формат `SHiNE_DM`.
|
||||||
|
|
||||||
### Запрос
|
### Запрос
|
||||||
|
|
||||||
@@ -110,8 +110,7 @@
|
|||||||
|
|
||||||
- `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64`
|
- `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64`
|
||||||
- `400 / BAD_BLOCK_FORMAT` — base64 или бинарный контейнер повреждён
|
- `400 / BAD_BLOCK_FORMAT` — base64 или бинарный контейнер повреждён
|
||||||
- `400 / BAD_CONTENT_FORMAT` — для контентного сообщения пришёл не `SHiNE_DM`
|
- `400 / BAD_CRYPTO_METHOD` — неподдерживаемый метод шифрования контейнера `EncryptedBody_v1_0`
|
||||||
- `400 / ATTACHMENTS_DISABLED` — в `SHiNE_DM` пришёл `attachmentsCount != 0`
|
|
||||||
- `404 / USER_NOT_FOUND` — один из логинов не найден
|
- `404 / USER_NOT_FOUND` — один из логинов не найден
|
||||||
- `460 / BAD_SIGNATURE` — подпись блока не прошла проверку
|
- `460 / BAD_SIGNATURE` — подпись блока не прошла проверку
|
||||||
|
|
||||||
@@ -123,6 +122,11 @@
|
|||||||
|
|
||||||
Используется там, где нужно принять только incoming-вариант сообщения.
|
Используется там, где нужно принять только incoming-вариант сообщения.
|
||||||
|
|
||||||
|
Принимаемые типы:
|
||||||
|
|
||||||
|
- `type=1` — входящая копия сообщения
|
||||||
|
- `type=3` — входящий read-receipt
|
||||||
|
|
||||||
### Запрос
|
### Запрос
|
||||||
|
|
||||||
```json
|
```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 в активные сессии адресата.
|
Сервер присылает его по WebSocket в активные сессии адресата.
|
||||||
|
|
||||||
@@ -174,18 +210,19 @@
|
|||||||
|
|
||||||
Если это новая ревизия того же письма, `messageKey` остаётся тем же, а `revisionTimeMs` меняется внутри бинарного блока.
|
Если это новая ревизия того же письма, `messageKey` остаётся тем же, а `revisionTimeMs` меняется внутри бинарного блока.
|
||||||
|
|
||||||
## 7. `CallInviteBroadcast`
|
Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`.
|
||||||
|
|
||||||
|
## 9. `CallInviteBroadcast`
|
||||||
|
|
||||||
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
|
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
|
||||||
|
|
||||||
## 8. `CallSignalToSession`
|
## 10. `CallSignalToSession`
|
||||||
|
|
||||||
Требует авторизации. Шлёт сигнал звонка в конкретную сессию.
|
Требует авторизации. Шлёт сигнал звонка в конкретную сессию.
|
||||||
|
|
||||||
## 9. Замечания
|
## 11. Замечания
|
||||||
|
|
||||||
- read-receipt `type=3/4` пока остаются в legacy-формате `SHiNE_dm2`
|
- все DM-типы `1..8` используют `SHiNE_DM`
|
||||||
- контентные DM `type=1/2` используют `SHiNE_DM`
|
- сервер не расшифровывает DM и не использует ciphertext как preview текста
|
||||||
- сервер хранит только последнюю версию контентного сообщения по `messageKey`
|
- сервер хранит последнюю применённую ревизию контентного сообщения и отдельные tombstone-события удаления
|
||||||
- удаление сообщения реализуется новой ревизией с пустым телом и `attachmentsCount = 0`
|
|
||||||
- HTTP endpoints для DM-файлов сейчас отсутствуют
|
- HTTP endpoints для DM-файлов сейчас отсутствуют
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 в коде, в том же наборе изменений обновляется и эта документация.
|
||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
## Статус документа
|
## Статус документа
|
||||||
|
|
||||||
Этот файл — актуальная логическая спецификация DM-протокола SHiNE следующего этапа.
|
Этот файл — актуальная логическая спецификация DM-протокола SHiNE v1.
|
||||||
|
|
||||||
Важно:
|
Важно:
|
||||||
|
|
||||||
- это целевая спецификация;
|
- это актуальная реализованная спецификация;
|
||||||
- в текущем коде она ещё реализована не полностью;
|
- код и документы по DM должны изменяться синхронно;
|
||||||
- при реализации код должен приводиться именно к этой спецификации, а не наоборот.
|
- при любых будущих изменениях DM сначала обновляется эта спецификация и соседний документ формата.
|
||||||
|
|
||||||
Документ фиксирует:
|
Документ фиксирует:
|
||||||
|
|
||||||
@@ -151,7 +151,6 @@
|
|||||||
|
|
||||||
- новые входящие сообщения;
|
- новые входящие сообщения;
|
||||||
- входящие обновления/редактирования;
|
- входящие обновления/редактирования;
|
||||||
- входящие служебные события удаления;
|
|
||||||
- будущие входящие перешифрованные копии.
|
- будущие входящие перешифрованные копии.
|
||||||
|
|
||||||
## 5. Удаление одного сообщения
|
## 5. Удаление одного сообщения
|
||||||
@@ -286,7 +285,7 @@
|
|||||||
Их роли в v1:
|
Их роли в v1:
|
||||||
|
|
||||||
- `SendMessagePair` — новая пара сообщений и редактирование старой пары автором;
|
- `SendMessagePair` — новая пара сообщений и редактирование старой пары автором;
|
||||||
- `ReceiveIncomingMessage` — приём одной входящей копии, входящих редактирований и служебных DM-событий;
|
- `ReceiveIncomingMessage` — приём одной входящей копии, входящих редактирований и входящего read-receipt;
|
||||||
- `ReceiveOutcomingMessage` — алиас `SendMessagePair`.
|
- `ReceiveOutcomingMessage` — алиас `SendMessagePair`.
|
||||||
|
|
||||||
### 8.2. Новые методы, которые нужны
|
### 8.2. Новые методы, которые нужны
|
||||||
@@ -399,7 +398,6 @@ Request:
|
|||||||
|
|
||||||
- приём одной входящей копии по схеме server-to-server;
|
- приём одной входящей копии по схеме server-to-server;
|
||||||
- приём входящего редактирования;
|
- приём входящего редактирования;
|
||||||
- приём служебного входящего события удаления;
|
|
||||||
- приём входящего read-receipt.
|
- приём входящего read-receipt.
|
||||||
|
|
||||||
Request:
|
Request:
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
|
|
||||||
Важно:
|
Важно:
|
||||||
|
|
||||||
- это целевой формат следующего этапа;
|
- это актуальный формат, реализованный в текущем коде;
|
||||||
- в текущем коде он ещё реализован не полностью;
|
- контейнер `SHiNE_DM` используется для всех типов `1..8`;
|
||||||
- особенно это касается перехода всех типов `1..8` на единый контейнер `SHiNE_DM`.
|
- при любых будущих изменениях DM этот документ обновляется одновременно с кодом.
|
||||||
|
|
||||||
Логика протокола, API и поведение сервера описаны отдельно:
|
Логика протокола, API и поведение сервера описаны отдельно:
|
||||||
|
|
||||||
@@ -156,6 +156,19 @@ Nonce/IV для `AES-GCM`.
|
|||||||
|
|
||||||
Сервер не должен трактовать это поле как UTF-8 текст и не должен пытаться расшифровывать его в обычной DM-логике.
|
Сервер не должен трактовать это поле как 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`
|
## 6. Поле `body` для типов `3/4`
|
||||||
|
|
||||||
Для read-receipt типов `3/4` поле `body` содержит открытый контейнер ссылки на исходное сообщение:
|
Для read-receipt типов `3/4` поле `body` содержит открытый контейнер ссылки на исходное сообщение:
|
||||||
@@ -291,8 +304,6 @@ ReadReceiptBody_v1_0
|
|||||||
- ciphertext может различаться;
|
- ciphertext может различаться;
|
||||||
- `reencryptedAtMs` при одной парной ревизии тоже должен совпадать.
|
- `reencryptedAtMs` при одной парной ревизии тоже должен совпадать.
|
||||||
|
|
||||||
## 13. Примечание о текущем коде
|
## 13. Примечание о поддержке
|
||||||
|
|
||||||
В текущем коде типы `3/4` пока ещё реализованы старым контейнером `SHiNE_dm2`.
|
В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`.
|
||||||
|
|
||||||
Для версии протокола v1 целевой формат фиксируется уже как единый `SHiNE_DM` для всех типов `1..8`.
|
|
||||||
|
|||||||
@@ -622,7 +622,7 @@ public final class DatabaseInitializer {
|
|||||||
ON signed_direct_messages_history (to_login, created_at_ms);
|
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("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS signed_messages_v2 (
|
CREATE TABLE IF NOT EXISTS signed_messages_v2 (
|
||||||
message_key TEXT NOT NULL PRIMARY KEY,
|
message_key TEXT NOT NULL PRIMARY KEY,
|
||||||
@@ -634,6 +634,7 @@ public final class DatabaseInitializer {
|
|||||||
nonce INTEGER NOT NULL,
|
nonce INTEGER NOT NULL,
|
||||||
message_type INTEGER NOT NULL,
|
message_type INTEGER NOT NULL,
|
||||||
revision_time_ms INTEGER NOT NULL DEFAULT 0,
|
revision_time_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
reencrypted_at_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
raw_block BLOB NOT NULL,
|
raw_block BLOB NOT NULL,
|
||||||
created_at_ms INTEGER NOT NULL,
|
created_at_ms INTEGER NOT NULL,
|
||||||
source_api TEXT NOT NULL,
|
source_api TEXT NOT NULL,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import java.sql.Statement;
|
|||||||
public final class SqliteDbController {
|
public final class SqliteDbController {
|
||||||
|
|
||||||
private static volatile SqliteDbController instance;
|
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;
|
private final String jdbcUrl;
|
||||||
|
|
||||||
@@ -92,6 +92,7 @@ public final class SqliteDbController {
|
|||||||
case 7 -> migrateToV7();
|
case 7 -> migrateToV7();
|
||||||
case 8 -> migrateToV8();
|
case 8 -> migrateToV8();
|
||||||
case 9 -> migrateToV9();
|
case 9 -> migrateToV9();
|
||||||
|
case 10 -> migrateToV10();
|
||||||
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
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 {
|
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS chat200_state (
|
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 {
|
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_login");
|
||||||
st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_message");
|
st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_message");
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import java.sql.PreparedStatement;
|
|||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public final class SignedMessagesV2DAO {
|
public final class SignedMessagesV2DAO {
|
||||||
@@ -31,9 +30,10 @@ public final class SignedMessagesV2DAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
INSERT OR IGNORE INTO signed_messages_v2 (
|
INSERT OR IGNORE INTO signed_messages_v2 (
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
receipt_ref_base_key, receipt_ref_type
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
bindSignedMessage(ps, e);
|
bindSignedMessage(ps, e);
|
||||||
@@ -72,22 +72,18 @@ public final class SignedMessagesV2DAO {
|
|||||||
boolean prevAutoCommit = c.getAutoCommit();
|
boolean prevAutoCommit = c.getAutoCommit();
|
||||||
c.setAutoCommit(false);
|
c.setAutoCommit(false);
|
||||||
try {
|
try {
|
||||||
Long currentIncomingRevision = getRevisionTimeMs(c, incoming.getMessageKey());
|
if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) {
|
||||||
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) {
|
|
||||||
c.rollback();
|
c.rollback();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (currentRevision != Long.MIN_VALUE
|
if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) {
|
||||||
&& nextRevision == currentRevision
|
c.rollback();
|
||||||
&& hasSameRawBlock(c, incoming)
|
return false;
|
||||||
&& hasSameRawBlock(c, outgoing)) {
|
}
|
||||||
|
|
||||||
|
Long currentRevision = getCurrentContentRevision(c, incoming.getBaseKey());
|
||||||
|
long nextRevision = incoming.getRevisionTimeMs();
|
||||||
|
if (currentRevision != null && nextRevision <= currentRevision) {
|
||||||
c.rollback();
|
c.rollback();
|
||||||
return false;
|
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 {
|
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
|
receipt_ref_base_key, receipt_ref_type
|
||||||
FROM signed_messages_v2
|
FROM signed_messages_v2
|
||||||
WHERE message_key = ?
|
WHERE message_key = ?
|
||||||
""";
|
""";
|
||||||
@@ -180,26 +266,35 @@ public final class SignedMessagesV2DAO {
|
|||||||
)
|
)
|
||||||
SELECT m.message_key, ?, 0, NULL, ?
|
SELECT m.message_key, ?, 0, NULL, ?
|
||||||
FROM signed_messages_v2 m
|
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();
|
long now = System.currentTimeMillis();
|
||||||
try (PreparedStatement ps = c.prepareStatement(fillSql)) {
|
try (PreparedStatement ps = c.prepareStatement(fillSql)) {
|
||||||
ps.setString(1, sessionId);
|
ps.setString(1, sessionId);
|
||||||
ps.setLong(2, now);
|
ps.setLong(2, now);
|
||||||
ps.setString(3, login);
|
ps.setString(3, login);
|
||||||
|
ps.setString(4, login);
|
||||||
|
ps.setString(5, login);
|
||||||
|
ps.setString(6, login);
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
|
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.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms,
|
||||||
m.source_api, m.origin_session_id, m.receipt_ref_base_key, m.receipt_ref_type
|
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
|
FROM signed_messages_v2 m
|
||||||
JOIN signed_message_session_delivery d
|
JOIN signed_message_session_delivery d
|
||||||
ON d.message_key = m.message_key
|
ON d.message_key = m.message_key
|
||||||
WHERE d.session_id = ? AND d.delivered = 0
|
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<SignedMessageV2Entry> out = new ArrayList<>();
|
List<SignedMessageV2Entry> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
@@ -216,9 +311,10 @@ public final class SignedMessagesV2DAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
INSERT INTO signed_messages_v2 (
|
INSERT INTO signed_messages_v2 (
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
receipt_ref_base_key, receipt_ref_type
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(message_key) DO UPDATE SET
|
ON CONFLICT(message_key) DO UPDATE SET
|
||||||
base_key = excluded.base_key,
|
base_key = excluded.base_key,
|
||||||
target_login = excluded.target_login,
|
target_login = excluded.target_login,
|
||||||
@@ -228,6 +324,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
nonce = excluded.nonce,
|
nonce = excluded.nonce,
|
||||||
message_type = excluded.message_type,
|
message_type = excluded.message_type,
|
||||||
revision_time_ms = excluded.revision_time_ms,
|
revision_time_ms = excluded.revision_time_ms,
|
||||||
|
reencrypted_at_ms = excluded.reencrypted_at_ms,
|
||||||
raw_block = excluded.raw_block,
|
raw_block = excluded.raw_block,
|
||||||
created_at_ms = excluded.created_at_ms,
|
created_at_ms = excluded.created_at_ms,
|
||||||
source_api = excluded.source_api,
|
source_api = excluded.source_api,
|
||||||
@@ -252,17 +349,122 @@ public final class SignedMessagesV2DAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean hasSameRawBlock(Connection c, SignedMessageV2Entry entry) throws SQLException {
|
private Long getCurrentContentRevision(Connection c, String baseKey) throws SQLException {
|
||||||
String sql = "SELECT raw_block FROM signed_messages_v2 WHERE message_key = ? LIMIT 1";
|
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)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, entry.getMessageKey());
|
ps.setString(1, baseKey);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
if (!rs.next()) return false;
|
if (!rs.next()) return null;
|
||||||
return Arrays.equals(rs.getBytes(1), entry.getRawBlock());
|
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 {
|
private void resetDeliveryRows(Connection c, String messageKey) throws SQLException {
|
||||||
try (PreparedStatement ps = c.prepareStatement("""
|
try (PreparedStatement ps = c.prepareStatement("""
|
||||||
UPDATE signed_message_session_delivery
|
UPDATE signed_message_session_delivery
|
||||||
@@ -278,9 +480,10 @@ public final class SignedMessagesV2DAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
INSERT INTO signed_messages_v2 (
|
INSERT INTO signed_messages_v2 (
|
||||||
message_key, base_key, target_login, from_login, to_login,
|
message_key, base_key, target_login, from_login, to_login,
|
||||||
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
|
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
raw_block, created_at_ms, source_api, origin_session_id,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
receipt_ref_base_key, receipt_ref_type
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
bindSignedMessage(ps, e);
|
bindSignedMessage(ps, e);
|
||||||
@@ -298,13 +501,30 @@ public final class SignedMessagesV2DAO {
|
|||||||
ps.setLong(7, e.getNonce());
|
ps.setLong(7, e.getNonce());
|
||||||
ps.setInt(8, e.getMessageType());
|
ps.setInt(8, e.getMessageType());
|
||||||
ps.setLong(9, e.getRevisionTimeMs());
|
ps.setLong(9, e.getRevisionTimeMs());
|
||||||
ps.setBytes(10, e.getRawBlock());
|
ps.setLong(10, e.getReencryptedAtMs());
|
||||||
ps.setLong(11, e.getCreatedAtMs());
|
ps.setBytes(11, e.getRawBlock());
|
||||||
ps.setString(12, e.getSourceApi());
|
ps.setLong(12, e.getCreatedAtMs());
|
||||||
ps.setString(13, e.getOriginSessionId());
|
ps.setString(13, e.getSourceApi());
|
||||||
ps.setString(14, e.getReceiptRefBaseKey());
|
ps.setString(14, e.getOriginSessionId());
|
||||||
if (e.getReceiptRefType() == null) ps.setObject(15, null);
|
ps.setString(15, e.getReceiptRefBaseKey());
|
||||||
else ps.setInt(15, e.getReceiptRefType());
|
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) {
|
private boolean isConstraintViolation(SQLException ex) {
|
||||||
@@ -323,6 +543,7 @@ public final class SignedMessagesV2DAO {
|
|||||||
e.setNonce(rs.getLong("nonce"));
|
e.setNonce(rs.getLong("nonce"));
|
||||||
e.setMessageType(rs.getInt("message_type"));
|
e.setMessageType(rs.getInt("message_type"));
|
||||||
e.setRevisionTimeMs(rs.getLong("revision_time_ms"));
|
e.setRevisionTimeMs(rs.getLong("revision_time_ms"));
|
||||||
|
e.setReencryptedAtMs(rs.getLong("reencrypted_at_ms"));
|
||||||
e.setRawBlock(rs.getBytes("raw_block"));
|
e.setRawBlock(rs.getBytes("raw_block"));
|
||||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||||
e.setSourceApi(rs.getString("source_api"));
|
e.setSourceApi(rs.getString("source_api"));
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public class SignedMessageV2Entry {
|
|||||||
private long nonce;
|
private long nonce;
|
||||||
private int messageType;
|
private int messageType;
|
||||||
private long revisionTimeMs;
|
private long revisionTimeMs;
|
||||||
|
private long reencryptedAtMs;
|
||||||
private byte[] rawBlock;
|
private byte[] rawBlock;
|
||||||
private long createdAtMs;
|
private long createdAtMs;
|
||||||
private String sourceApi;
|
private String sourceApi;
|
||||||
@@ -35,6 +36,8 @@ public class SignedMessageV2Entry {
|
|||||||
public void setMessageType(int messageType) { this.messageType = messageType; }
|
public void setMessageType(int messageType) { this.messageType = messageType; }
|
||||||
public long getRevisionTimeMs() { return revisionTimeMs; }
|
public long getRevisionTimeMs() { return revisionTimeMs; }
|
||||||
public void setRevisionTimeMs(long revisionTimeMs) { this.revisionTimeMs = 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 byte[] getRawBlock() { return rawBlock; }
|
||||||
public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; }
|
public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; }
|
||||||
public long getCreatedAtMs() { return createdAtMs; }
|
public long getCreatedAtMs() { return createdAtMs; }
|
||||||
|
|||||||
+8
@@ -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_AckSessionDelivery_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_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_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_SendSignal_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_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_AckSessionDelivery_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_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_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_SendSignal_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_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("SendMessagePair", new Net_SendMessagePair_Handler()),
|
||||||
Map.entry("ReceiveOutcomingMessage", new Net_SendMessagePair_Handler()),
|
Map.entry("ReceiveOutcomingMessage", new Net_SendMessagePair_Handler()),
|
||||||
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_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("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
||||||
Map.entry("CallSignalToSession", new Net_CallSignalToSession_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("SendMessagePair", Net_SendMessagePair_Request.class),
|
||||||
Map.entry("ReceiveOutcomingMessage", Net_SendMessagePair_Request.class),
|
Map.entry("ReceiveOutcomingMessage", Net_SendMessagePair_Request.class),
|
||||||
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_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("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
||||||
Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class),
|
Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class),
|
||||||
|
|||||||
+40
-2
@@ -16,8 +16,10 @@ import java.net.http.HttpResponse;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazy-import пользователя из Solana PDA в локальную БД сервера.
|
* Lazy-import пользователя из Solana PDA в локальную БД сервера.
|
||||||
@@ -99,6 +101,28 @@ public final class SolanaUserPdaImportService {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static List<ParsedServerRoute> 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<String, ParsedServerRoute> 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 {
|
private static ParsedSolanaUser fetchFromSolana(String login) throws Exception {
|
||||||
byte[] raw = fetchRawUserPda(login);
|
byte[] raw = fetchRawUserPda(login);
|
||||||
if (raw == null) return null;
|
if (raw == null) return null;
|
||||||
@@ -184,6 +208,7 @@ public final class SolanaUserPdaImportService {
|
|||||||
byte[] clientKey32 = null;
|
byte[] clientKey32 = null;
|
||||||
long paidLimitBytes = 0L;
|
long paidLimitBytes = 0L;
|
||||||
List<ParsedSessionRecord> sessions = new ArrayList<>();
|
List<ParsedSessionRecord> sessions = new ArrayList<>();
|
||||||
|
List<String> accessServers = new ArrayList<>();
|
||||||
|
|
||||||
for (int i = 0; i < blocksCount; i++) {
|
for (int i = 0; i < blocksCount; i++) {
|
||||||
int blockType = u8(raw, c++);
|
int blockType = u8(raw, c++);
|
||||||
@@ -239,7 +264,9 @@ public final class SolanaUserPdaImportService {
|
|||||||
int accessCount = u8(raw, c++);
|
int accessCount = u8(raw, c++);
|
||||||
for (int j = 0; j < accessCount; j++) {
|
for (int j = 0; j < accessCount; j++) {
|
||||||
int n = u8(raw, c++);
|
int n = u8(raw, c++);
|
||||||
|
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||||
c += n;
|
c += n;
|
||||||
|
accessServers.add(normalizeLogin(accessServerLogin));
|
||||||
}
|
}
|
||||||
} else if (blockType == 50) {
|
} else if (blockType == 50) {
|
||||||
int sessionsMode = u8(raw, c++);
|
int sessionsMode = u8(raw, c++);
|
||||||
@@ -277,7 +304,8 @@ public final class SolanaUserPdaImportService {
|
|||||||
Base64.getEncoder().encodeToString(blockchainKey32),
|
Base64.getEncoder().encodeToString(blockchainKey32),
|
||||||
Base64.getEncoder().encodeToString(clientKey32),
|
Base64.getEncoder().encodeToString(clientKey32),
|
||||||
paidLimitBytes,
|
paidLimitBytes,
|
||||||
sessions
|
sessions,
|
||||||
|
accessServers
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,13 +466,18 @@ public final class SolanaUserPdaImportService {
|
|||||||
return diff == 0;
|
return diff == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String safe(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
private record ParsedSolanaUser(
|
private record ParsedSolanaUser(
|
||||||
String login,
|
String login,
|
||||||
String blockchainName,
|
String blockchainName,
|
||||||
String blockchainKeyB64,
|
String blockchainKeyB64,
|
||||||
String clientKeyB64,
|
String clientKeyB64,
|
||||||
long paidLimitBytes,
|
long paidLimitBytes,
|
||||||
List<ParsedSessionRecord> sessions
|
List<ParsedSessionRecord> sessions,
|
||||||
|
List<String> accessServers
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private record ParsedSessionRecord(
|
private record ParsedSessionRecord(
|
||||||
@@ -479,4 +512,9 @@ public final class SolanaUserPdaImportService {
|
|||||||
String serverAddress,
|
String serverAddress,
|
||||||
List<String> syncServers
|
List<String> syncServers
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public record ParsedServerRoute(
|
||||||
|
String login,
|
||||||
|
String serverAddress
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+70
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-3
@@ -25,7 +25,8 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
|
|||||||
} catch (IllegalArgumentException ex) {
|
} catch (IllegalArgumentException ex) {
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат входящего блока");
|
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");
|
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 подтверждения");
|
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();
|
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
if (inserted) {
|
if (inserted) {
|
||||||
counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null);
|
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
|
||||||
}
|
}
|
||||||
|
|
||||||
Net_ReceiveIncomingMessage_Response resp = new Net_ReceiveIncomingMessage_Response();
|
Net_ReceiveIncomingMessage_Response resp = new Net_ReceiveIncomingMessage_Response();
|
||||||
|
|||||||
+13
-3
@@ -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.messages.entyties.Net_SendMessagePair_Response;
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
import server.logic.ws_protocol.WireCodes;
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import server.sync.DmFederationService;
|
||||||
import shine.db.dao.SignedMessagesV2DAO;
|
import shine.db.dao.SignedMessagesV2DAO;
|
||||||
import shine.db.entities.SignedMessageV2Entry;
|
import shine.db.entities.SignedMessageV2Entry;
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
|||||||
SignedMessageV2Entry incomingEntry;
|
SignedMessageV2Entry incomingEntry;
|
||||||
SignedMessageV2Entry outgoingEntry;
|
SignedMessageV2Entry outgoingEntry;
|
||||||
try {
|
try {
|
||||||
String sourceApi = "ReceiveOutcomingMessage";
|
String sourceApi = "SendMessagePair";
|
||||||
String originSessionId = (ctx != null && !isBlank(ctx.getSessionId())) ? ctx.getSessionId() : null;
|
String originSessionId = (ctx != null && !isBlank(ctx.getSessionId())) ? ctx.getSessionId() : null;
|
||||||
incomingEntry = SignedMessagesCore.toEntry(incoming, sourceApi, originSessionId);
|
incomingEntry = SignedMessagesCore.toEntry(incoming, sourceApi, originSessionId);
|
||||||
outgoingEntry = SignedMessagesCore.toEntry(outgoing, 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();
|
SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
if (pairInserted) {
|
if (pairInserted) {
|
||||||
inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, incoming);
|
inCounters = SignedMessagesRealtime.deliverToRelevantSessions(incomingEntry, incoming);
|
||||||
}
|
}
|
||||||
|
|
||||||
String excludeSessionId = null;
|
String excludeSessionId = null;
|
||||||
@@ -69,7 +70,16 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
|
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
if (pairInserted) {
|
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();
|
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
|
||||||
|
|||||||
+3
-9
@@ -9,18 +9,16 @@ final class ReadReceiptPayload {
|
|||||||
final String refFromLogin;
|
final String refFromLogin;
|
||||||
final long refTimeMs;
|
final long refTimeMs;
|
||||||
final long refNonce;
|
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.refToLogin = refToLogin;
|
||||||
this.refFromLogin = refFromLogin;
|
this.refFromLogin = refFromLogin;
|
||||||
this.refTimeMs = refTimeMs;
|
this.refTimeMs = refTimeMs;
|
||||||
this.refNonce = refNonce;
|
this.refNonce = refNonce;
|
||||||
this.refType = refType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static ReadReceiptPayload parse(byte[] payload) {
|
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");
|
throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN");
|
||||||
}
|
}
|
||||||
ByteBuffer bb = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN);
|
||||||
@@ -29,14 +27,10 @@ final class ReadReceiptPayload {
|
|||||||
long refTimeMs = bb.getLong();
|
long refTimeMs = bb.getLong();
|
||||||
if (refTimeMs < 0) throw new IllegalArgumentException("BAD_RECEIPT_TIME");
|
if (refTimeMs < 0) throw new IllegalArgumentException("BAD_RECEIPT_TIME");
|
||||||
long refNonce = Integer.toUnsignedLong(bb.getInt());
|
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()) {
|
if (bb.hasRemaining()) {
|
||||||
throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN");
|
throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN");
|
||||||
}
|
}
|
||||||
return new ReadReceiptPayload(refTo, refFrom, refTimeMs, refNonce, refType);
|
return new ReadReceiptPayload(refTo, refFrom, refTimeMs, refNonce);
|
||||||
}
|
}
|
||||||
|
|
||||||
String refBaseKey() {
|
String refBaseKey() {
|
||||||
|
|||||||
+81
-100
@@ -6,12 +6,16 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
|
||||||
final class SignedMessageBlock {
|
final class SignedMessageBlock {
|
||||||
static final byte[] LEGACY_PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII);
|
static final byte[] PREFIX = "SHiNE_DM".getBytes(StandardCharsets.US_ASCII);
|
||||||
static final byte[] V1_PREFIX = "SHiNE_DM".getBytes(StandardCharsets.US_ASCII);
|
|
||||||
static final int TYPE_INCOMING_TEXT = 1;
|
static final int TYPE_INCOMING_TEXT = 1;
|
||||||
static final int TYPE_OUTGOING_COPY = 2;
|
static final int TYPE_OUTGOING_COPY = 2;
|
||||||
static final int TYPE_READ_INCOMING = 3;
|
static final int TYPE_READ_INCOMING = 3;
|
||||||
static final int TYPE_READ_OUTGOING_COPY = 4;
|
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 toLogin;
|
||||||
final String fromLogin;
|
final String fromLogin;
|
||||||
@@ -19,14 +23,15 @@ final class SignedMessageBlock {
|
|||||||
final long nonce;
|
final long nonce;
|
||||||
final int messageType;
|
final int messageType;
|
||||||
final long revisionTimeMs;
|
final long revisionTimeMs;
|
||||||
|
final long reencryptedAtMs;
|
||||||
final int formatVersionMajor;
|
final int formatVersionMajor;
|
||||||
final int formatVersionMinor;
|
final int formatVersionMinor;
|
||||||
final byte[] payloadBytes;
|
final byte[] bodyBytes;
|
||||||
final byte[] encryptedBodyBytes;
|
final EncryptedBodyPayload encryptedBody;
|
||||||
|
final ReadReceiptPayload readReceiptBody;
|
||||||
final byte[] signedBody;
|
final byte[] signedBody;
|
||||||
final byte[] signature64;
|
final byte[] signature64;
|
||||||
final byte[] rawPacket;
|
final byte[] rawPacket;
|
||||||
final boolean legacyFormat;
|
|
||||||
|
|
||||||
private SignedMessageBlock(
|
private SignedMessageBlock(
|
||||||
String toLogin,
|
String toLogin,
|
||||||
@@ -35,14 +40,15 @@ final class SignedMessageBlock {
|
|||||||
long nonce,
|
long nonce,
|
||||||
int messageType,
|
int messageType,
|
||||||
long revisionTimeMs,
|
long revisionTimeMs,
|
||||||
|
long reencryptedAtMs,
|
||||||
int formatVersionMajor,
|
int formatVersionMajor,
|
||||||
int formatVersionMinor,
|
int formatVersionMinor,
|
||||||
byte[] payloadBytes,
|
byte[] bodyBytes,
|
||||||
byte[] encryptedBodyBytes,
|
EncryptedBodyPayload encryptedBody,
|
||||||
|
ReadReceiptPayload readReceiptBody,
|
||||||
byte[] signedBody,
|
byte[] signedBody,
|
||||||
byte[] signature64,
|
byte[] signature64,
|
||||||
byte[] rawPacket,
|
byte[] rawPacket
|
||||||
boolean legacyFormat
|
|
||||||
) {
|
) {
|
||||||
this.toLogin = toLogin;
|
this.toLogin = toLogin;
|
||||||
this.fromLogin = fromLogin;
|
this.fromLogin = fromLogin;
|
||||||
@@ -50,85 +56,27 @@ final class SignedMessageBlock {
|
|||||||
this.nonce = nonce;
|
this.nonce = nonce;
|
||||||
this.messageType = messageType;
|
this.messageType = messageType;
|
||||||
this.revisionTimeMs = revisionTimeMs;
|
this.revisionTimeMs = revisionTimeMs;
|
||||||
|
this.reencryptedAtMs = reencryptedAtMs;
|
||||||
this.formatVersionMajor = formatVersionMajor;
|
this.formatVersionMajor = formatVersionMajor;
|
||||||
this.formatVersionMinor = formatVersionMinor;
|
this.formatVersionMinor = formatVersionMinor;
|
||||||
this.payloadBytes = payloadBytes;
|
this.bodyBytes = bodyBytes;
|
||||||
this.encryptedBodyBytes = encryptedBodyBytes;
|
this.encryptedBody = encryptedBody;
|
||||||
|
this.readReceiptBody = readReceiptBody;
|
||||||
this.signedBody = signedBody;
|
this.signedBody = signedBody;
|
||||||
this.signature64 = signature64;
|
this.signature64 = signature64;
|
||||||
this.rawPacket = rawPacket;
|
this.rawPacket = rawPacket;
|
||||||
this.legacyFormat = legacyFormat;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static SignedMessageBlock parse(byte[] raw, int maxEncryptedBodyBytes) {
|
static SignedMessageBlock parse(byte[] raw, int maxBodyBytes) {
|
||||||
if (raw == null || raw.length < 64) {
|
if (raw == null || raw.length < PREFIX.length + 2 + 1 + 1 + 8 + 4 + 1 + 8 + 8 + 4 + 64) {
|
||||||
throw new IllegalArgumentException("BAD_LEN");
|
throw new IllegalArgumentException("BAD_LEN");
|
||||||
}
|
}
|
||||||
|
if (!startsWith(raw, PREFIX)) {
|
||||||
if (startsWith(raw, LEGACY_PREFIX)) {
|
|
||||||
return parseLegacy(raw, maxEncryptedBodyBytes);
|
|
||||||
}
|
|
||||||
if (startsWith(raw, V1_PREFIX)) {
|
|
||||||
return parseV1(raw, maxEncryptedBodyBytes);
|
|
||||||
}
|
|
||||||
throw new IllegalArgumentException("BAD_PREFIX");
|
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);
|
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
|
||||||
bb.position(LEGACY_PREFIX.length);
|
bb.position(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);
|
|
||||||
|
|
||||||
int major = Byte.toUnsignedInt(bb.get());
|
int major = Byte.toUnsignedInt(bb.get());
|
||||||
int minor = Byte.toUnsignedInt(bb.get());
|
int minor = Byte.toUnsignedInt(bb.get());
|
||||||
@@ -141,32 +89,55 @@ final class SignedMessageBlock {
|
|||||||
long timeMs = bb.getLong();
|
long timeMs = bb.getLong();
|
||||||
if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME");
|
if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME");
|
||||||
long nonce = Integer.toUnsignedLong(bb.getInt());
|
long nonce = Integer.toUnsignedLong(bb.getInt());
|
||||||
int messageType = Short.toUnsignedInt(bb.getShort());
|
int messageType = Byte.toUnsignedInt(bb.get());
|
||||||
ensureMessageType(messageType);
|
ensureMessageType(messageType);
|
||||||
long revisionTimeMs = bb.getLong();
|
long revisionTimeMs = bb.getLong();
|
||||||
if (revisionTimeMs < 0) throw new IllegalArgumentException("BAD_REVISION_TIME");
|
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());
|
long bodyLen = Integer.toUnsignedLong(bb.getInt());
|
||||||
if (attachmentsCount != 0) {
|
if (bodyLen > maxBodyBytes) {
|
||||||
throw new IllegalArgumentException("ATTACHMENTS_DISABLED");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bb.remaining() < 4 + 64) {
|
|
||||||
throw new IllegalArgumentException("BAD_LEN");
|
|
||||||
}
|
|
||||||
long encryptedBodyLen = Integer.toUnsignedLong(bb.getInt());
|
|
||||||
if (encryptedBodyLen > maxEncryptedBodyBytes) {
|
|
||||||
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
||||||
}
|
}
|
||||||
if (bb.remaining() != encryptedBodyLen + 64) {
|
if (bb.remaining() != bodyLen + 64) {
|
||||||
throw new IllegalArgumentException("BAD_LEN");
|
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];
|
byte[] signature64 = new byte[64];
|
||||||
bb.get(signature64);
|
bb.get(signature64);
|
||||||
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
|
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(
|
return new SignedMessageBlock(
|
||||||
toLogin,
|
toLogin,
|
||||||
fromLogin,
|
fromLogin,
|
||||||
@@ -174,23 +145,24 @@ final class SignedMessageBlock {
|
|||||||
nonce,
|
nonce,
|
||||||
messageType,
|
messageType,
|
||||||
revisionTimeMs,
|
revisionTimeMs,
|
||||||
|
reencryptedAtMs,
|
||||||
major,
|
major,
|
||||||
minor,
|
minor,
|
||||||
|
bodyBytes,
|
||||||
encryptedBody,
|
encryptedBody,
|
||||||
encryptedBody,
|
readReceiptBody,
|
||||||
signedBody,
|
signedBody,
|
||||||
signature64,
|
signature64,
|
||||||
raw,
|
raw
|
||||||
false
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isIncomingType() {
|
boolean isIncomingCopyType() {
|
||||||
return messageType == TYPE_INCOMING_TEXT || messageType == TYPE_READ_INCOMING;
|
return messageType == TYPE_INCOMING_TEXT;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isOutgoingCopyType() {
|
boolean isOutgoingCopyType() {
|
||||||
return messageType == TYPE_OUTGOING_COPY || messageType == TYPE_READ_OUTGOING_COPY;
|
return messageType == TYPE_OUTGOING_COPY;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isContentType() {
|
boolean isContentType() {
|
||||||
@@ -201,16 +173,25 @@ final class SignedMessageBlock {
|
|||||||
return messageType == TYPE_READ_INCOMING || messageType == TYPE_READ_OUTGOING_COPY;
|
return messageType == TYPE_READ_INCOMING || messageType == TYPE_READ_OUTGOING_COPY;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isDeletedContent() {
|
boolean isMessageDeleteType() {
|
||||||
return isContentType() && !legacyFormat && encryptedBodyBytes.length == 0;
|
return messageType == TYPE_MESSAGE_DELETED_BY_SENDER || messageType == TYPE_MESSAGE_DELETED_BY_RECIPIENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
String targetLogin() {
|
boolean isConversationDeleteType() {
|
||||||
return isIncomingType() ? toLogin : fromLogin;
|
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) {
|
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");
|
throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-39
@@ -5,26 +5,24 @@ import shine.db.entities.SignedMessageV2Entry;
|
|||||||
import shine.db.entities.SolanaUserEntry;
|
import shine.db.entities.SolanaUserEntry;
|
||||||
import utils.crypto.Ed25519Util;
|
import utils.crypto.Ed25519Util;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
|
||||||
final class SignedMessagesCore {
|
final class SignedMessagesCore {
|
||||||
private static final int MAX_ENCRYPTED_BODY_BYTES = 16384;
|
private static final int MAX_BODY_BYTES = 16384;
|
||||||
|
|
||||||
private SignedMessagesCore() {}
|
private SignedMessagesCore() {}
|
||||||
|
|
||||||
static SignedMessageBlock parseFromB64(String blobB64) {
|
static SignedMessageBlock parseFromB64(String blobB64) {
|
||||||
try {
|
try {
|
||||||
byte[] raw = Base64.getDecoder().decode(blobB64.trim());
|
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) {
|
} catch (IllegalArgumentException e) {
|
||||||
String code = e.getMessage();
|
String code = e.getMessage();
|
||||||
if (code == null || code.isBlank()) {
|
if (code == null || code.isBlank()) {
|
||||||
throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
|
throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
|
||||||
}
|
}
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case "ATTACHMENTS_DISABLED",
|
case "BAD_PREFIX",
|
||||||
"BAD_PREFIX",
|
|
||||||
"BAD_LEN",
|
"BAD_LEN",
|
||||||
"BAD_TO_LOGIN",
|
"BAD_TO_LOGIN",
|
||||||
"BAD_FROM_LOGIN",
|
"BAD_FROM_LOGIN",
|
||||||
@@ -32,7 +30,16 @@ final class SignedMessagesCore {
|
|||||||
"BAD_MESSAGE_TYPE",
|
"BAD_MESSAGE_TYPE",
|
||||||
"BAD_MESSAGE_LEN",
|
"BAD_MESSAGE_LEN",
|
||||||
"BAD_FORMAT_VERSION",
|
"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");
|
default -> throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,7 +51,10 @@ final class SignedMessagesCore {
|
|||||||
if (from == null || to == null) {
|
if (from == null || to == null) {
|
||||||
throw new IllegalArgumentException("USER_NOT_FOUND");
|
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)) {
|
if (!Ed25519Util.verify(block.signedBody, block.signature64, pubKey32)) {
|
||||||
throw new IllegalArgumentException("BAD_SIGNATURE");
|
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.fromLogin.equalsIgnoreCase(outgoing.fromLogin)) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
||||||
if (incoming.timeMs != outgoing.timeMs) 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.nonce != outgoing.nonce) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
||||||
|
if (incoming.revisionTimeMs != outgoing.revisionTimeMs) throw new IllegalArgumentException("BAD_REVISION_TIME");
|
||||||
if (incoming.isReadReceiptType()) {
|
if (incoming.reencryptedAtMs != outgoing.reencryptedAtMs) throw new IllegalArgumentException("BAD_REENCRYPTED_TIME");
|
||||||
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.formatVersionMajor != outgoing.formatVersionMajor
|
if (incoming.formatVersionMajor != outgoing.formatVersionMajor
|
||||||
|| incoming.formatVersionMinor != outgoing.formatVersionMinor) {
|
|| incoming.formatVersionMinor != outgoing.formatVersionMinor) {
|
||||||
throw new IllegalArgumentException("BAD_FORMAT_VERSION");
|
throw new IllegalArgumentException("BAD_FORMAT_VERSION");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (incoming.encryptedBodyBytes.length != outgoing.encryptedBodyBytes.length) {
|
if (incoming.isReadReceiptType()) {
|
||||||
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
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");
|
||||||
}
|
}
|
||||||
for (int i = 0; i < incoming.encryptedBodyBytes.length; i++) {
|
return;
|
||||||
if (incoming.encryptedBodyBytes[i] != outgoing.encryptedBodyBytes[i]) {
|
|
||||||
throw new IllegalArgumentException("BAD_ENCRYPTED_BODY");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!incoming.isContentType() || !outgoing.isContentType()) {
|
||||||
|
throw new IllegalArgumentException("BAD_PAIR_TYPES");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,32 +99,40 @@ final class SignedMessagesCore {
|
|||||||
SignedMessageV2Entry entry = new SignedMessageV2Entry();
|
SignedMessageV2Entry entry = new SignedMessageV2Entry();
|
||||||
entry.setMessageKey(messageKey);
|
entry.setMessageKey(messageKey);
|
||||||
entry.setBaseKey(baseKey);
|
entry.setBaseKey(baseKey);
|
||||||
entry.setTargetLogin(block.targetLogin());
|
entry.setTargetLogin(primaryTargetLogin(block));
|
||||||
entry.setFromLogin(block.fromLogin);
|
entry.setFromLogin(block.fromLogin);
|
||||||
entry.setToLogin(block.toLogin);
|
entry.setToLogin(block.toLogin);
|
||||||
entry.setTimeMs(block.timeMs);
|
entry.setTimeMs(block.timeMs);
|
||||||
entry.setNonce(block.nonce);
|
entry.setNonce(block.nonce);
|
||||||
entry.setMessageType(block.messageType);
|
entry.setMessageType(block.messageType);
|
||||||
entry.setRevisionTimeMs(block.revisionTimeMs);
|
entry.setRevisionTimeMs(block.revisionTimeMs);
|
||||||
|
entry.setReencryptedAtMs(block.reencryptedAtMs);
|
||||||
entry.setRawBlock(block.rawPacket);
|
entry.setRawBlock(block.rawPacket);
|
||||||
entry.setCreatedAtMs(System.currentTimeMillis());
|
entry.setCreatedAtMs(System.currentTimeMillis());
|
||||||
entry.setSourceApi(sourceApi);
|
entry.setSourceApi(sourceApi);
|
||||||
entry.setOriginSessionId(originSessionId);
|
entry.setOriginSessionId(originSessionId);
|
||||||
|
|
||||||
if (block.messageType == SignedMessageBlock.TYPE_READ_INCOMING
|
if (block.isReadReceiptType()) {
|
||||||
|| block.messageType == SignedMessageBlock.TYPE_READ_OUTGOING_COPY) {
|
ReadReceiptPayload ref = block.readReceiptBody;
|
||||||
ReadReceiptPayload ref = ReadReceiptPayload.parse(block.payloadBytes);
|
|
||||||
entry.setReceiptRefBaseKey(ref.refBaseKey());
|
entry.setReceiptRefBaseKey(ref.refBaseKey());
|
||||||
entry.setReceiptRefType(ref.refType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String previewTextForPush(SignedMessageBlock block) {
|
static String previewTextForPush(SignedMessageBlock block) {
|
||||||
if (!block.isContentType() || block.encryptedBodyBytes == null || block.encryptedBodyBytes.length == 0) {
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
return new String(block.encryptedBodyBytes, StandardCharsets.UTF_8);
|
|
||||||
|
private static String primaryTargetLogin(SignedMessageBlock block) {
|
||||||
|
if (block.messageType == SignedMessageBlock.TYPE_INCOMING_TEXT
|
||||||
|
|| block.messageType == SignedMessageBlock.TYPE_READ_INCOMING) {
|
||||||
|
return block.toLogin;
|
||||||
|
}
|
||||||
|
if (block.messageType == SignedMessageBlock.TYPE_OUTGOING_COPY
|
||||||
|
|| block.messageType == SignedMessageBlock.TYPE_READ_OUTGOING_COPY) {
|
||||||
|
return block.fromLogin;
|
||||||
|
}
|
||||||
|
return block.toLogin;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-26
@@ -13,45 +13,48 @@ import shine.db.dao.SignedMessagesV2DAO;
|
|||||||
import shine.db.entities.ActiveSessionEntry;
|
import shine.db.entities.ActiveSessionEntry;
|
||||||
import shine.db.entities.SignedMessageV2Entry;
|
import shine.db.entities.SignedMessageV2Entry;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
public final class SignedMessagesRealtime {
|
public final class SignedMessagesRealtime {
|
||||||
private static final Logger log = LoggerFactory.getLogger(SignedMessagesRealtime.class);
|
private static final Logger log = LoggerFactory.getLogger(SignedMessagesRealtime.class);
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
private SignedMessagesRealtime() {}
|
private SignedMessagesRealtime() {}
|
||||||
|
|
||||||
static DeliveryCounters deliverToTargetSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception {
|
static DeliveryCounters deliverToRelevantSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception {
|
||||||
return deliverToTargetSessions(message, block, null);
|
return deliverToRelevantSessions(message, block, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
static DeliveryCounters deliverToTargetSessions(
|
static DeliveryCounters deliverToRelevantSessions(
|
||||||
SignedMessageV2Entry message,
|
SignedMessageV2Entry message,
|
||||||
SignedMessageBlock block,
|
SignedMessageBlock block,
|
||||||
String excludeSessionId
|
String excludeSessionId
|
||||||
) throws Exception {
|
) throws Exception {
|
||||||
DeliveryCounters counters = new DeliveryCounters();
|
DeliveryCounters counters = new DeliveryCounters();
|
||||||
List<ActiveSessionEntry> sessions = ActiveSessionsDAO.getInstance().getByLogin(message.getTargetLogin());
|
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
|
for (String targetLogin : targetLoginsForMessage(message)) {
|
||||||
|
List<ActiveSessionEntry> sessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin);
|
||||||
for (ActiveSessionEntry s : sessions) {
|
for (ActiveSessionEntry s : sessions) {
|
||||||
String sessionId = s.getSessionId();
|
String sessionId = s.getSessionId();
|
||||||
if (excludeSessionId != null && excludeSessionId.equals(sessionId)) {
|
if (excludeSessionId != null && excludeSessionId.equals(sessionId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
SignedMessagesV2DAO.getInstance().ensureDeliveryRow(message.getMessageKey(), sessionId, now);
|
SignedMessagesV2DAO.getInstance().ensureDeliveryRow(message.getMessageKey(), sessionId, now);
|
||||||
boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, message, false);
|
boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, targetLogin, message, false);
|
||||||
if (deliveredOnline) {
|
if (deliveredOnline) {
|
||||||
counters.wsDelivered++;
|
counters.wsDelivered++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT
|
if (shouldPushNewIncomingMessage(targetLogin, message, block)) {
|
||||||
&& block != null
|
boolean pushed = pushNewMessageNotification(s, message);
|
||||||
&& block.revisionTimeMs == 0
|
|
||||||
&& !block.isDeletedContent()) {
|
|
||||||
boolean pushed = pushNewMessageNotification(s, message, block);
|
|
||||||
if (pushed) counters.pushDelivered++;
|
if (pushed) counters.pushDelivered++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return counters;
|
return counters;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,14 +68,19 @@ public final class SignedMessagesRealtime {
|
|||||||
List<SignedMessageV2Entry> pending = SignedMessagesV2DAO.getInstance()
|
List<SignedMessageV2Entry> pending = SignedMessagesV2DAO.getInstance()
|
||||||
.listPendingForSession(login, sessionId);
|
.listPendingForSession(login, sessionId);
|
||||||
for (SignedMessageV2Entry e : pending) {
|
for (SignedMessageV2Entry e : pending) {
|
||||||
sendEventToSessionIfOnline(sessionId, e, true);
|
sendEventToSessionIfOnline(sessionId, login, e, true);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to dispatch pending messages for sessionId={}", sessionId, 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);
|
ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId);
|
||||||
if (targetCtx == null) return false;
|
if (targetCtx == null) return false;
|
||||||
|
|
||||||
@@ -82,7 +90,7 @@ public final class SignedMessagesRealtime {
|
|||||||
payload.put("baseKey", message.getBaseKey());
|
payload.put("baseKey", message.getBaseKey());
|
||||||
payload.put("fromLogin", message.getFromLogin());
|
payload.put("fromLogin", message.getFromLogin());
|
||||||
payload.put("toLogin", message.getToLogin());
|
payload.put("toLogin", message.getToLogin());
|
||||||
payload.put("targetLogin", message.getTargetLogin());
|
payload.put("targetLogin", actualTargetLogin);
|
||||||
payload.put("messageType", message.getMessageType());
|
payload.put("messageType", message.getMessageType());
|
||||||
payload.put("timeMs", message.getTimeMs());
|
payload.put("timeMs", message.getTimeMs());
|
||||||
payload.put("nonce", message.getNonce());
|
payload.put("nonce", message.getNonce());
|
||||||
@@ -91,27 +99,23 @@ public final class SignedMessagesRealtime {
|
|||||||
if (message.getReceiptRefBaseKey() != null) {
|
if (message.getReceiptRefBaseKey() != null) {
|
||||||
payload.put("receiptRefBaseKey", message.getReceiptRefBaseKey());
|
payload.put("receiptRefBaseKey", message.getReceiptRefBaseKey());
|
||||||
}
|
}
|
||||||
if (message.getReceiptRefType() != null) {
|
|
||||||
payload.put("receiptRefType", message.getReceiptRefType());
|
|
||||||
}
|
|
||||||
return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload);
|
return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean pushNewMessageNotification(
|
private static boolean shouldPushNewIncomingMessage(String targetLogin, SignedMessageV2Entry message, SignedMessageBlock block) {
|
||||||
ActiveSessionEntry session,
|
if (block == null) return false;
|
||||||
SignedMessageV2Entry message,
|
if (message.getMessageType() != SignedMessageBlock.TYPE_INCOMING_TEXT) return false;
|
||||||
SignedMessageBlock block
|
if (!targetLogin.equalsIgnoreCase(message.getToLogin())) return false;
|
||||||
) {
|
return block.revisionTimeMs == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageV2Entry message) {
|
||||||
try {
|
try {
|
||||||
if (session == null) return false;
|
if (session == null) return false;
|
||||||
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
|
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
String preview = SignedMessagesCore.previewTextForPush(block).replace('\n', ' ').trim();
|
String text = "Вам пришло новое личное сообщение от " + message.getFromLogin() + ".";
|
||||||
if (preview.length() > 80) preview = preview.substring(0, 80) + "...";
|
|
||||||
String text = preview.isBlank()
|
|
||||||
? "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения."
|
|
||||||
: preview;
|
|
||||||
String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}";
|
String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}";
|
||||||
return WebPushSender.sendBase64Payload(
|
return WebPushSender.sendBase64Payload(
|
||||||
session.getPushEndpoint(),
|
session.getPushEndpoint(),
|
||||||
@@ -124,6 +128,28 @@ public final class SignedMessagesRealtime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<String> targetLoginsForMessage(SignedMessageV2Entry message) {
|
||||||
|
Set<String> 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) {
|
private static String jsonEscape(String s) {
|
||||||
if (s == null) return "";
|
if (s == null) return "";
|
||||||
StringBuilder out = new StringBuilder();
|
StringBuilder out = new StringBuilder();
|
||||||
|
|||||||
+10
@@ -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; }
|
||||||
|
}
|
||||||
+19
@@ -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; }
|
||||||
|
}
|
||||||
+10
@@ -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; }
|
||||||
|
}
|
||||||
+19
@@ -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; }
|
||||||
|
}
|
||||||
+100
@@ -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<String, SolanaUserPdaImportService.ParsedServerRoute> senderRoutes =
|
||||||
|
routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin));
|
||||||
|
Map<String, SolanaUserPdaImportService.ParsedServerRoute> 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<String, SolanaUserPdaImportService.ParsedServerRoute> 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<String, SolanaUserPdaImportService.ParsedServerRoute> routesByLogin(
|
||||||
|
List<SolanaUserPdaImportService.ParsedServerRoute>... routeLists
|
||||||
|
) {
|
||||||
|
Map<String, SolanaUserPdaImportService.ParsedServerRoute> out = new LinkedHashMap<>();
|
||||||
|
for (List<SolanaUserPdaImportService.ParsedServerRoute> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+177
@@ -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<String> 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<String> responseFuture;
|
||||||
|
private final CountDownLatch openLatch;
|
||||||
|
private final StringBuilder textBuffer = new StringBuilder();
|
||||||
|
|
||||||
|
private SyncWsListener(CompletableFuture<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.297
|
client.version=1.2.298
|
||||||
server.version=1.2.277
|
server.version=1.2.278
|
||||||
|
|||||||
+43
-4
@@ -27,6 +27,8 @@ import {
|
|||||||
state,
|
state,
|
||||||
terminateCurrentSession,
|
terminateCurrentSession,
|
||||||
addSignedMessageToChat,
|
addSignedMessageToChat,
|
||||||
|
deleteConversationMessagesBefore,
|
||||||
|
deleteSignedMessageByBaseKey,
|
||||||
markIncomingReadByBaseKey,
|
markIncomingReadByBaseKey,
|
||||||
markOutgoingReadByBaseKey,
|
markOutgoingReadByBaseKey,
|
||||||
normalizeDmChatId,
|
normalizeDmChatId,
|
||||||
@@ -1018,10 +1020,29 @@ async function init() {
|
|||||||
const fromLogin = parsed.fromLogin || '';
|
const fromLogin = parsed.fromLogin || '';
|
||||||
const toLogin = parsed.toLogin || '';
|
const toLogin = parsed.toLogin || '';
|
||||||
const messageType = Number(parsed.messageType || 0);
|
const messageType = Number(parsed.messageType || 0);
|
||||||
const chatId = normalizeDmChatId(messageType === 2 ? toLogin : fromLogin);
|
const currentLogin = String(state.session?.login || '').trim().toLowerCase();
|
||||||
const text = (messageType === 1 || messageType === 2)
|
const chatPeerLogin = currentLogin && currentLogin === String(fromLogin || '').trim().toLowerCase()
|
||||||
? String(parsed.text || '')
|
? 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;
|
let shouldRefreshToolbarUnread = false;
|
||||||
|
|
||||||
@@ -1084,6 +1105,24 @@ async function init() {
|
|||||||
message: 'Получено подтверждение прочтения',
|
message: 'Получено подтверждение прочтения',
|
||||||
details: { messageKey, baseKey: parsed.baseKey, messageType },
|
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 {
|
try {
|
||||||
|
|||||||
@@ -594,16 +594,25 @@ export function render({ navigate, route }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyLocalRevision = ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
|
const applyLocalRevision = async ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
|
||||||
if (!localOutgoingBlobB64) return;
|
if (!localOutgoingBlobB64) return;
|
||||||
try {
|
try {
|
||||||
const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64);
|
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({
|
addSignedMessageToChat({
|
||||||
chatId,
|
chatId,
|
||||||
messageKey: fallbackMessageKey || parsed?.messageKey || '',
|
messageKey: fallbackMessageKey || parsed?.messageKey || '',
|
||||||
baseKey: fallbackBaseKey || parsed?.baseKey || '',
|
baseKey: fallbackBaseKey || parsed?.baseKey || '',
|
||||||
from: 'out',
|
from: 'out',
|
||||||
text: parsed?.text || '',
|
text,
|
||||||
messageType: Number(parsed?.messageType || 2),
|
messageType: Number(parsed?.messageType || 2),
|
||||||
unread: false,
|
unread: false,
|
||||||
rawBlobB64: localOutgoingBlobB64,
|
rawBlobB64: localOutgoingBlobB64,
|
||||||
@@ -625,11 +634,13 @@ export function render({ navigate, route }) {
|
|||||||
timeMs: base.timeMs,
|
timeMs: base.timeMs,
|
||||||
nonce: base.nonce,
|
nonce: base.nonce,
|
||||||
revisionTimeMs: Date.now(),
|
revisionTimeMs: Date.now(),
|
||||||
|
deleteByRecipient: msg?.from === 'in',
|
||||||
});
|
});
|
||||||
applyLocalRevision({
|
addSignedMessageToChat({
|
||||||
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
|
chatId,
|
||||||
fallbackMessageKey: result?.outgoingKey || '',
|
messageKey: String(msg?.messageKey || ''),
|
||||||
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
|
baseKey: String(msg?.baseKey || ''),
|
||||||
|
deleted: true,
|
||||||
});
|
});
|
||||||
if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) {
|
if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) {
|
||||||
cancelEditMode({ restoreDraft: true });
|
cancelEditMode({ restoreDraft: true });
|
||||||
@@ -670,7 +681,7 @@ export function render({ navigate, route }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
applyLocalRevision({
|
void applyLocalRevision({
|
||||||
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
|
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
|
||||||
fallbackMessageKey: result?.outgoingKey || '',
|
fallbackMessageKey: result?.outgoingKey || '',
|
||||||
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
|
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
|
||||||
@@ -857,7 +868,6 @@ async function sendReadReceiptsForVisible(chatId) {
|
|||||||
refFromLogin: ref.fromLogin,
|
refFromLogin: ref.fromLogin,
|
||||||
refTimeMs: ref.timeMs,
|
refTimeMs: ref.timeMs,
|
||||||
refNonce: ref.nonce,
|
refNonce: ref.nonce,
|
||||||
refType: 1,
|
|
||||||
});
|
});
|
||||||
if (row.baseKey) {
|
if (row.baseKey) {
|
||||||
markReadReceiptSentByBaseKey(row.baseKey);
|
markReadReceiptSentByBaseKey(row.baseKey);
|
||||||
|
|||||||
@@ -4,16 +4,26 @@ import {
|
|||||||
bytesToBase64,
|
bytesToBase64,
|
||||||
deriveEd25519FromMasterSecret,
|
deriveEd25519FromMasterSecret,
|
||||||
deriveMasterSecretFromPassword,
|
deriveMasterSecretFromPassword,
|
||||||
|
ed25519PublicToX25519Public,
|
||||||
|
ed25519SeedToX25519Private,
|
||||||
exportEd25519PublicKeyB64,
|
exportEd25519PublicKeyB64,
|
||||||
exportPkcs8B64,
|
exportPkcs8B64,
|
||||||
generateEd25519Pair,
|
generateEd25519Pair,
|
||||||
|
extractEd25519SeedFromPkcs8B64,
|
||||||
|
hkdfSha256,
|
||||||
importPkcs8Ed25519,
|
importPkcs8Ed25519,
|
||||||
publicKeyB64FromPkcs8Ed25519,
|
publicKeyB64FromPkcs8Ed25519,
|
||||||
randomBase64,
|
randomBase64,
|
||||||
|
randomBytes,
|
||||||
sha256Bytes,
|
sha256Bytes,
|
||||||
signBytes,
|
signBytes,
|
||||||
signBase64,
|
signBase64,
|
||||||
utf8Bytes,
|
utf8Bytes,
|
||||||
|
x25519PublicFromPrivate,
|
||||||
|
x25519RandomPrivateKey,
|
||||||
|
x25519SharedSecret,
|
||||||
|
encryptBytesAesGcm,
|
||||||
|
decryptBytesAesGcm,
|
||||||
} from './crypto-utils.js';
|
} from './crypto-utils.js';
|
||||||
import {
|
import {
|
||||||
channelNameErrorText,
|
channelNameErrorText,
|
||||||
@@ -215,14 +225,20 @@ function uint8Bytes(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM');
|
const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM');
|
||||||
const DM2_PREFIX = utf8Bytes('SHiNE_dm2');
|
const DM_TYPE_INCOMING = 1;
|
||||||
const DM2_TYPE_INCOMING = 1;
|
const DM_TYPE_OUTGOING_COPY = 2;
|
||||||
const DM2_TYPE_OUTGOING_COPY = 2;
|
const DM_TYPE_READ_INCOMING = 3;
|
||||||
const DM2_TYPE_READ_INCOMING = 3;
|
const DM_TYPE_READ_OUTGOING_COPY = 4;
|
||||||
const DM2_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_MAJOR = 1;
|
||||||
const DM_FORMAT_VERSION_MINOR = 0;
|
const DM_FORMAT_VERSION_MINOR = 0;
|
||||||
const DM_MAX_ENCRYPTED_BODY_BYTES = 16384;
|
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) {
|
function ensureAsciiBytes(value, field, min = 1, max = 60) {
|
||||||
const text = String(value || '').trim();
|
const text = String(value || '').trim();
|
||||||
@@ -237,15 +253,15 @@ function ensureAsciiBytes(value, field, min = 1, max = 60) {
|
|||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dm2BaseKey({ toLogin, fromLogin, timeMs, nonce }) {
|
function dmBaseKey({ toLogin, fromLogin, timeMs, nonce }) {
|
||||||
return `${fromLogin}|${toLogin}|${Number(timeMs)}|${Number(nonce)}`;
|
return `${fromLogin}|${toLogin}|${Number(timeMs)}|${Number(nonce)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }) {
|
function dmMessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }) {
|
||||||
return `${dm2BaseKey({ toLogin, fromLogin, timeMs, nonce })}|${Number(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 toBytes = ensureAsciiBytes(refToLogin, 'receipt.refToLogin');
|
||||||
const fromBytes = ensureAsciiBytes(refFromLogin, 'receipt.refFromLogin');
|
const fromBytes = ensureAsciiBytes(refFromLogin, 'receipt.refFromLogin');
|
||||||
return concatBytes(
|
return concatBytes(
|
||||||
@@ -253,10 +269,67 @@ function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, ref
|
|||||||
uint8Bytes(fromBytes.length), fromBytes,
|
uint8Bytes(fromBytes.length), fromBytes,
|
||||||
uint64Bytes(refTimeMs),
|
uint64Bytes(refTimeMs),
|
||||||
uint32Bytes(refNonce),
|
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) {
|
function parseSignedMessageBlockBytes(bytes) {
|
||||||
if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array');
|
if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array');
|
||||||
let o = 0;
|
let o = 0;
|
||||||
@@ -267,11 +340,6 @@ function parseSignedMessageBlockBytes(bytes) {
|
|||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
const readU8 = () => read(1)[0];
|
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 readU32 = () => {
|
||||||
const part = read(4);
|
const part = read(4);
|
||||||
const view = new DataView(part.buffer, part.byteOffset, 4);
|
const view = new DataView(part.buffer, part.byteOffset, 4);
|
||||||
@@ -301,7 +369,8 @@ function parseSignedMessageBlockBytes(bytes) {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (startsWith(DM_PREFIX_V1)) {
|
if (!startsWith(DM_PREFIX_V1)) throw new Error('BAD_PREFIX');
|
||||||
|
|
||||||
read(DM_PREFIX_V1.length);
|
read(DM_PREFIX_V1.length);
|
||||||
const formatVersionMajor = readU8();
|
const formatVersionMajor = readU8();
|
||||||
const formatVersionMinor = readU8();
|
const formatVersionMinor = readU8();
|
||||||
@@ -309,18 +378,25 @@ function parseSignedMessageBlockBytes(bytes) {
|
|||||||
const fromLogin = readAscii();
|
const fromLogin = readAscii();
|
||||||
const timeMs = readU64();
|
const timeMs = readU64();
|
||||||
const nonce = readU32();
|
const nonce = readU32();
|
||||||
const messageType = readU16();
|
const messageType = readU8();
|
||||||
const revisionTimeMs = readU64();
|
const revisionTimeMs = readU64();
|
||||||
const attachmentsCount = readU8();
|
const reencryptedAtMs = readU64();
|
||||||
if (attachmentsCount !== 0) throw new Error('ATTACHMENTS_DISABLED');
|
const bodyLen = readU32();
|
||||||
const encryptedBodyLen = readU32();
|
const bodyBytes = read(bodyLen);
|
||||||
const encryptedBodyBytes = read(encryptedBodyLen);
|
|
||||||
const signatureBytes = read(64);
|
const signatureBytes = read(64);
|
||||||
if (o !== bytes.length) throw new Error('BAD_LEN');
|
if (o !== bytes.length) throw new Error('BAD_LEN');
|
||||||
|
|
||||||
const signedBody = bytes.slice(0, bytes.length - 64);
|
const signedBody = bytes.slice(0, bytes.length - 64);
|
||||||
const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce });
|
const baseKey = dmBaseKey({ toLogin, fromLogin, timeMs, nonce });
|
||||||
const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
|
const messageKey = dmMessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
|
||||||
const bodyText = new TextDecoder().decode(encryptedBodyBytes);
|
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 {
|
return {
|
||||||
toLogin,
|
toLogin,
|
||||||
fromLogin,
|
fromLogin,
|
||||||
@@ -328,60 +404,24 @@ function parseSignedMessageBlockBytes(bytes) {
|
|||||||
nonce,
|
nonce,
|
||||||
messageType,
|
messageType,
|
||||||
revisionTimeMs,
|
revisionTimeMs,
|
||||||
|
reencryptedAtMs,
|
||||||
formatVersionMajor,
|
formatVersionMajor,
|
||||||
formatVersionMinor,
|
formatVersionMinor,
|
||||||
encryptedBodyBytes,
|
encryptedBodyBytes: bodyBytes,
|
||||||
encryptedBodyText: bodyText,
|
encryptedBodyPayload,
|
||||||
text: bodyText,
|
text: '',
|
||||||
bodyAttachments: [],
|
bodyAttachments: [],
|
||||||
payloadBytes: encryptedBodyBytes,
|
payloadBytes: bodyBytes,
|
||||||
signatureBytes,
|
signatureBytes,
|
||||||
signedBody,
|
signedBody,
|
||||||
rawBytes: bytes,
|
rawBytes: bytes,
|
||||||
baseKey,
|
baseKey,
|
||||||
messageKey,
|
messageKey,
|
||||||
legacyFormat: false,
|
legacyFormat: false,
|
||||||
deleted: attachmentsCount === 0 && encryptedBodyLen === 0,
|
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,
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
const toLogin = readAscii();
|
|
||||||
const fromLogin = readAscii();
|
|
||||||
const timeMs = readU64();
|
|
||||||
const nonce = readU32();
|
|
||||||
const messageType = readU16();
|
|
||||||
const payloadLen = readU16();
|
|
||||||
const payloadBytes = read(payloadLen);
|
|
||||||
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 });
|
|
||||||
return {
|
|
||||||
toLogin,
|
|
||||||
fromLogin,
|
|
||||||
timeMs,
|
|
||||||
nonce,
|
|
||||||
messageType,
|
|
||||||
revisionTimeMs: 0,
|
|
||||||
encryptedBodyBytes: payloadBytes,
|
|
||||||
encryptedBodyText: new TextDecoder().decode(payloadBytes),
|
|
||||||
text: new TextDecoder().decode(payloadBytes),
|
|
||||||
bodyAttachments: [],
|
|
||||||
payloadBytes,
|
|
||||||
signatureBytes,
|
|
||||||
signedBody,
|
|
||||||
rawBytes: bytes,
|
|
||||||
baseKey,
|
|
||||||
messageKey,
|
|
||||||
legacyFormat: true,
|
|
||||||
deleted: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2186,64 +2226,28 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async buildSignedDm2Block({
|
async buildSignedDmBlock({
|
||||||
login,
|
signerLogin,
|
||||||
toLogin,
|
fromLogin,
|
||||||
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,
|
|
||||||
toLogin,
|
toLogin,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
timeMs,
|
timeMs,
|
||||||
nonce,
|
nonce,
|
||||||
messageType,
|
messageType,
|
||||||
revisionTimeMs = 0,
|
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();
|
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 (!storagePwd) throw new Error('Не передан storagePwd для подписи');
|
||||||
if (!(encryptedBodyBytes instanceof Uint8Array) || encryptedBodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) {
|
if (!(bodyBytes instanceof Uint8Array) || bodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) {
|
||||||
throw new Error(`encryptedBody должен быть 0..${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;
|
const clientPriv = secrets?.clientKey || secrets?.clientKey;
|
||||||
if (!clientPriv) throw new Error('Не найден приватный clientKey');
|
if (!clientPriv) throw new Error('Не найден приватный clientKey');
|
||||||
const privateKey = await importPkcs8Ed25519(clientPriv);
|
const privateKey = await importPkcs8Ed25519(clientPriv);
|
||||||
@@ -2258,11 +2262,11 @@ export class AuthService {
|
|||||||
uint8Bytes(fromBytes.length), fromBytes,
|
uint8Bytes(fromBytes.length), fromBytes,
|
||||||
uint64Bytes(timeMs),
|
uint64Bytes(timeMs),
|
||||||
uint32Bytes(nonce),
|
uint32Bytes(nonce),
|
||||||
uint16Bytes(messageType),
|
uint8Bytes(messageType),
|
||||||
uint64Bytes(revisionTimeMs),
|
uint64Bytes(revisionTimeMs),
|
||||||
uint8Bytes(0),
|
uint64Bytes(reencryptedAtMs),
|
||||||
uint32Bytes(encryptedBodyBytes.length),
|
uint32Bytes(bodyBytes.length),
|
||||||
encryptedBodyBytes,
|
bodyBytes,
|
||||||
);
|
);
|
||||||
const signature = await signBytes(privateKey, preimage);
|
const signature = await signBytes(privateKey, preimage);
|
||||||
return concatBytes(preimage, signature);
|
return concatBytes(preimage, signature);
|
||||||
@@ -2283,10 +2287,6 @@ export class AuthService {
|
|||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
const readU8 = () => read(1)[0];
|
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 readU32 = () => {
|
||||||
const part = read(4);
|
const part = read(4);
|
||||||
return new DataView(part.buffer, part.byteOffset, 4).getUint32(0, false);
|
return new DataView(part.buffer, part.byteOffset, 4).getUint32(0, false);
|
||||||
@@ -2304,9 +2304,40 @@ export class AuthService {
|
|||||||
const refFromLogin = readAscii();
|
const refFromLogin = readAscii();
|
||||||
const refTimeMs = readU64();
|
const refTimeMs = readU64();
|
||||||
const refNonce = readU32();
|
const refNonce = readU32();
|
||||||
const refType = readU16();
|
|
||||||
if (o !== payloadBytes.length) throw new Error('BAD_RECEIPT_LEN');
|
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 }) {
|
async sendMessagePair({ incomingBlobB64, outgoingBlobB64 }) {
|
||||||
@@ -2330,6 +2361,7 @@ export class AuthService {
|
|||||||
timeMs,
|
timeMs,
|
||||||
nonce,
|
nonce,
|
||||||
revisionTimeMs = 0,
|
revisionTimeMs = 0,
|
||||||
|
reencryptedAtMs = 0,
|
||||||
}) {
|
}) {
|
||||||
const cleanFromLogin = String(login || '').trim();
|
const cleanFromLogin = String(login || '').trim();
|
||||||
const cleanToLogin = String(toLogin || '').trim();
|
const cleanToLogin = String(toLogin || '').trim();
|
||||||
@@ -2338,30 +2370,54 @@ export class AuthService {
|
|||||||
const normalizedTimeMs = Number(timeMs);
|
const normalizedTimeMs = Number(timeMs);
|
||||||
const normalizedNonce = Number(nonce);
|
const normalizedNonce = Number(nonce);
|
||||||
const normalizedRevisionTimeMs = Number(revisionTimeMs || 0);
|
const normalizedRevisionTimeMs = Number(revisionTimeMs || 0);
|
||||||
|
const normalizedReencryptedAtMs = Number(reencryptedAtMs || 0);
|
||||||
if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs');
|
if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs');
|
||||||
if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce');
|
if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce');
|
||||||
if (!Number.isFinite(normalizedRevisionTimeMs) || normalizedRevisionTimeMs < 0) throw new Error('Некорректный revisionTimeMs');
|
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({
|
const plainBytes = utf8Bytes(cleanText);
|
||||||
login: cleanFromLogin,
|
const targetUser = await this.getUser(cleanToLogin);
|
||||||
toLogin: cleanToLogin,
|
if (!targetUser?.exists || !String(targetUser?.clientKey || '').trim()) {
|
||||||
storagePwd,
|
throw new Error('Не найден clientKey получателя');
|
||||||
timeMs: normalizedTimeMs,
|
}
|
||||||
nonce: normalizedNonce,
|
const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
|
||||||
messageType: DM2_TYPE_INCOMING,
|
const senderPrivPkcs8 = String(secrets?.clientKey || '').trim();
|
||||||
revisionTimeMs: normalizedRevisionTimeMs,
|
if (!senderPrivPkcs8) throw new Error('Не найден приватный clientKey отправителя');
|
||||||
encryptedBodyBytes,
|
const senderPublicKeyB64 = await publicKeyB64FromPkcs8Ed25519(senderPrivPkcs8);
|
||||||
|
|
||||||
|
const incomingEncryptedBodyBytes = await buildDmEncryptedBodyBytes({
|
||||||
|
plainBytes,
|
||||||
|
recipientClientKeyB64: String(targetUser.clientKey || '').trim(),
|
||||||
});
|
});
|
||||||
const outgoingBlock = await this.buildSignedDmV1Block({
|
const outgoingEncryptedBodyBytes = await buildDmEncryptedBodyBytes({
|
||||||
login: cleanFromLogin,
|
plainBytes,
|
||||||
|
recipientClientKeyB64: senderPublicKeyB64,
|
||||||
|
});
|
||||||
|
|
||||||
|
const incomingBlock = await this.buildSignedDmBlock({
|
||||||
|
signerLogin: cleanFromLogin,
|
||||||
|
fromLogin: cleanFromLogin,
|
||||||
toLogin: cleanToLogin,
|
toLogin: cleanToLogin,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
timeMs: normalizedTimeMs,
|
timeMs: normalizedTimeMs,
|
||||||
nonce: normalizedNonce,
|
nonce: normalizedNonce,
|
||||||
messageType: DM2_TYPE_OUTGOING_COPY,
|
messageType: DM_TYPE_INCOMING,
|
||||||
revisionTimeMs: normalizedRevisionTimeMs,
|
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({
|
const payload = await this.sendMessagePair({
|
||||||
@@ -2372,7 +2428,7 @@ export class AuthService {
|
|||||||
...payload,
|
...payload,
|
||||||
localIncomingBlobB64: bytesToBase64(incomingBlock),
|
localIncomingBlobB64: bytesToBase64(incomingBlock),
|
||||||
localOutgoingBlobB64: bytesToBase64(outgoingBlock),
|
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 }) {
|
async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs, deleteByRecipient = false }) {
|
||||||
return this.sendDirectMessageRevision({
|
const cleanLogin = String(login || '').trim();
|
||||||
login,
|
const cleanPeerLogin = String(toLogin || '').trim();
|
||||||
toLogin,
|
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
|
||||||
text: '',
|
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,
|
storagePwd,
|
||||||
timeMs,
|
timeMs: normalizedTimeMs,
|
||||||
nonce,
|
nonce: normalizedNonce,
|
||||||
revisionTimeMs,
|
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 timeMs = Date.now();
|
||||||
const nonce = Math.floor(Math.random() * 0x100000000);
|
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({
|
const type3 = await this.buildSignedDmBlock({
|
||||||
login,
|
signerLogin: login,
|
||||||
|
fromLogin: login,
|
||||||
toLogin,
|
toLogin,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
timeMs,
|
timeMs,
|
||||||
nonce,
|
nonce,
|
||||||
messageType: DM2_TYPE_READ_INCOMING,
|
messageType: DM_TYPE_READ_INCOMING,
|
||||||
payloadBytes: payload,
|
bodyBytes: payload,
|
||||||
});
|
});
|
||||||
const type4 = await this.buildSignedDm2Block({
|
const type4 = await this.buildSignedDmBlock({
|
||||||
login,
|
signerLogin: login,
|
||||||
|
fromLogin: login,
|
||||||
toLogin,
|
toLogin,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
timeMs,
|
timeMs,
|
||||||
nonce,
|
nonce,
|
||||||
messageType: DM2_TYPE_READ_OUTGOING_COPY,
|
messageType: DM_TYPE_READ_OUTGOING_COPY,
|
||||||
payloadBytes: payload,
|
bodyBytes: payload,
|
||||||
});
|
});
|
||||||
return this.sendMessagePair({
|
return this.sendMessagePair({
|
||||||
incomingBlobB64: bytesToBase64(type3),
|
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) {
|
async ackSessionDelivery(messageKey) {
|
||||||
const response = await this.ws.request('AckSessionDelivery', { messageKey });
|
const response = await this.ws.request('AckSessionDelivery', { messageKey });
|
||||||
if (response.status !== 200) throw opError('AckSessionDelivery', response);
|
if (response.status !== 200) throw opError('AckSessionDelivery', response);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.';
|
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.';
|
||||||
import { argon2idAsync } from 'https://esm.sh/@noble/hashes@1.8.0/argon2.js';
|
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';
|
const SHINE_KEY_DERIVATION_PREFIX = 'SHiNE-key';
|
||||||
|
|
||||||
function getCryptoApi() {
|
function getCryptoApi() {
|
||||||
@@ -340,3 +341,56 @@ export async function signBytes(privateKey, bytes) {
|
|||||||
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, bytes);
|
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, bytes);
|
||||||
return new Uint8Array(signature);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -660,6 +660,45 @@ export function addSignedMessageToChat({
|
|||||||
return true;
|
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) {
|
export function markChatRead(chatId) {
|
||||||
const normalizedChatId = normalizeDmChatId(chatId);
|
const normalizedChatId = normalizeDmChatId(chatId);
|
||||||
const list = getChatMessages(normalizedChatId);
|
const list = getChatMessages(normalizedChatId);
|
||||||
|
|||||||
Reference in New Issue
Block a user