SHA256
Реализовать SHiNE_DM v1 с E2EE и tombstone
This commit is contained in:
@@ -61,6 +61,8 @@
|
||||
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
|
||||
| `ReceiveOutcomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | алиас `SendMessagePair` |
|
||||
| `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока |
|
||||
| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон |
|
||||
| `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки |
|
||||
| `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию |
|
||||
| `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку |
|
||||
| `CallSignalToSession` | `12_Direct_Messages_Push_Calls_API.md` | сигнал звонка в конкретную сессию |
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
- `incomingBlobB64` — блок `type=1` или `type=3`
|
||||
- `outgoingBlobB64` — блок `type=2` или `type=4`
|
||||
|
||||
Для контентных сообщений `type=1/2` внутри base64 лежит бинарный формат `SHiNE_DM`.
|
||||
Все типы в этой паре используют бинарный формат `SHiNE_DM`.
|
||||
|
||||
### Запрос
|
||||
|
||||
@@ -110,8 +110,7 @@
|
||||
|
||||
- `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64`
|
||||
- `400 / BAD_BLOCK_FORMAT` — base64 или бинарный контейнер повреждён
|
||||
- `400 / BAD_CONTENT_FORMAT` — для контентного сообщения пришёл не `SHiNE_DM`
|
||||
- `400 / ATTACHMENTS_DISABLED` — в `SHiNE_DM` пришёл `attachmentsCount != 0`
|
||||
- `400 / BAD_CRYPTO_METHOD` — неподдерживаемый метод шифрования контейнера `EncryptedBody_v1_0`
|
||||
- `404 / USER_NOT_FOUND` — один из логинов не найден
|
||||
- `460 / BAD_SIGNATURE` — подпись блока не прошла проверку
|
||||
|
||||
@@ -123,6 +122,11 @@
|
||||
|
||||
Используется там, где нужно принять только incoming-вариант сообщения.
|
||||
|
||||
Принимаемые типы:
|
||||
|
||||
- `type=1` — входящая копия сообщения
|
||||
- `type=3` — входящий read-receipt
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
@@ -135,7 +139,39 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 5. `AckSessionDelivery`
|
||||
## 5. `DeleteMessage`
|
||||
|
||||
Принимает один signed DM-блок `type=5` или `type=6`.
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "DeleteMessage",
|
||||
"requestId": "dm-del-001",
|
||||
"payload": {
|
||||
"blobB64": "BASE64_DELETE_BLOCK"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. `DeleteConversation`
|
||||
|
||||
Принимает один signed DM-блок `type=7` или `type=8`.
|
||||
|
||||
### Запрос
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "DeleteConversation",
|
||||
"requestId": "dm-del-all-001",
|
||||
"payload": {
|
||||
"blobB64": "BASE64_DELETE_CONVERSATION_BLOCK"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. `AckSessionDelivery`
|
||||
|
||||
Требует авторизации. Подтверждает доставку в текущую сессию.
|
||||
|
||||
@@ -151,7 +187,7 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Событие `SignedMessageArrived`
|
||||
## 8. Событие `SignedMessageArrived`
|
||||
|
||||
Сервер присылает его по WebSocket в активные сессии адресата.
|
||||
|
||||
@@ -174,18 +210,19 @@
|
||||
|
||||
Если это новая ревизия того же письма, `messageKey` остаётся тем же, а `revisionTimeMs` меняется внутри бинарного блока.
|
||||
|
||||
## 7. `CallInviteBroadcast`
|
||||
Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`.
|
||||
|
||||
## 9. `CallInviteBroadcast`
|
||||
|
||||
Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`.
|
||||
|
||||
## 8. `CallSignalToSession`
|
||||
## 10. `CallSignalToSession`
|
||||
|
||||
Требует авторизации. Шлёт сигнал звонка в конкретную сессию.
|
||||
|
||||
## 9. Замечания
|
||||
## 11. Замечания
|
||||
|
||||
- read-receipt `type=3/4` пока остаются в legacy-формате `SHiNE_dm2`
|
||||
- контентные DM `type=1/2` используют `SHiNE_DM`
|
||||
- сервер хранит только последнюю версию контентного сообщения по `messageKey`
|
||||
- удаление сообщения реализуется новой ревизией с пустым телом и `attachmentsCount = 0`
|
||||
- все DM-типы `1..8` используют `SHiNE_DM`
|
||||
- сервер не расшифровывает DM и не использует ciphertext как preview текста
|
||||
- сервер хранит последнюю применённую ревизию контентного сообщения и отдельные tombstone-события удаления
|
||||
- HTTP endpoints для DM-файлов сейчас отсутствуют
|
||||
|
||||
@@ -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. Удаление одного сообщения
|
||||
@@ -286,7 +285,7 @@
|
||||
Их роли в v1:
|
||||
|
||||
- `SendMessagePair` — новая пара сообщений и редактирование старой пары автором;
|
||||
- `ReceiveIncomingMessage` — приём одной входящей копии, входящих редактирований и служебных DM-событий;
|
||||
- `ReceiveIncomingMessage` — приём одной входящей копии, входящих редактирований и входящего read-receipt;
|
||||
- `ReceiveOutcomingMessage` — алиас `SendMessagePair`.
|
||||
|
||||
### 8.2. Новые методы, которые нужны
|
||||
@@ -399,7 +398,6 @@ Request:
|
||||
|
||||
- приём одной входящей копии по схеме server-to-server;
|
||||
- приём входящего редактирования;
|
||||
- приём служебного входящего события удаления;
|
||||
- приём входящего read-receipt.
|
||||
|
||||
Request:
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
Важно:
|
||||
|
||||
- это целевой формат следующего этапа;
|
||||
- в текущем коде он ещё реализован не полностью;
|
||||
- особенно это касается перехода всех типов `1..8` на единый контейнер `SHiNE_DM`.
|
||||
- это актуальный формат, реализованный в текущем коде;
|
||||
- контейнер `SHiNE_DM` используется для всех типов `1..8`;
|
||||
- при любых будущих изменениях DM этот документ обновляется одновременно с кодом.
|
||||
|
||||
Логика протокола, API и поведение сервера описаны отдельно:
|
||||
|
||||
@@ -156,6 +156,19 @@ Nonce/IV для `AES-GCM`.
|
||||
|
||||
Сервер не должен трактовать это поле как UTF-8 текст и не должен пытаться расшифровывать его в обычной DM-логике.
|
||||
|
||||
### Точная схема для `cryptoMethod = 1`, `cryptoVersion = 0`
|
||||
|
||||
- публичный ключ получателя для E2EE получается как стандартное преобразование `Ed25519 -> X25519`;
|
||||
- отправитель генерирует ephemeral `X25519` приватный ключ и кладёт соответствующий `ephemeralPubKey` в контейнер;
|
||||
- общий секрет вычисляется как `X25519(ephemeralPrivKey, recipientX25519PubKey)`;
|
||||
- `HKDF-SHA256` использует:
|
||||
- `salt = ephemeralPubKey || recipientX25519PubKey`
|
||||
- `info = "SHiNE_DM|1|0|X25519+HKDF-SHA256+AES-256-GCM"` в ASCII
|
||||
- `outputLen = 32`
|
||||
- полученные `32` байта используются как ключ `AES-256-GCM`;
|
||||
- поле `cipherText` хранит стандартный результат библиотеки `AES-GCM` в виде:
|
||||
- `ciphertext || tag`
|
||||
|
||||
## 6. Поле `body` для типов `3/4`
|
||||
|
||||
Для read-receipt типов `3/4` поле `body` содержит открытый контейнер ссылки на исходное сообщение:
|
||||
@@ -291,8 +304,6 @@ ReadReceiptBody_v1_0
|
||||
- ciphertext может различаться;
|
||||
- `reencryptedAtMs` при одной парной ревизии тоже должен совпадать.
|
||||
|
||||
## 13. Примечание о текущем коде
|
||||
## 13. Примечание о поддержке
|
||||
|
||||
В текущем коде типы `3/4` пока ещё реализованы старым контейнером `SHiNE_dm2`.
|
||||
|
||||
Для версии протокола v1 целевой формат фиксируется уже как единый `SHiNE_DM` для всех типов `1..8`.
|
||||
В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`.
|
||||
|
||||
@@ -622,7 +622,7 @@ public final class DatabaseInitializer {
|
||||
ON signed_direct_messages_history (to_login, created_at_ms);
|
||||
""");
|
||||
|
||||
// 13) signed_messages_v2 (универсальное хранилище блоков типов 1/2/3/4)
|
||||
// 13) signed_messages_v2 (универсальное хранилище блоков типов 1..8)
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS signed_messages_v2 (
|
||||
message_key TEXT NOT NULL PRIMARY KEY,
|
||||
@@ -634,6 +634,7 @@ public final class DatabaseInitializer {
|
||||
nonce INTEGER NOT NULL,
|
||||
message_type INTEGER NOT NULL,
|
||||
revision_time_ms INTEGER NOT NULL DEFAULT 0,
|
||||
reencrypted_at_ms INTEGER NOT NULL DEFAULT 0,
|
||||
raw_block BLOB NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
source_api TEXT NOT NULL,
|
||||
|
||||
@@ -14,7 +14,7 @@ import java.sql.Statement;
|
||||
public final class SqliteDbController {
|
||||
|
||||
private static volatile SqliteDbController instance;
|
||||
private static final int LATEST_SCHEMA_VERSION = 9;
|
||||
private static final int LATEST_SCHEMA_VERSION = 10;
|
||||
|
||||
private final String jdbcUrl;
|
||||
|
||||
@@ -92,6 +92,7 @@ public final class SqliteDbController {
|
||||
case 7 -> migrateToV7();
|
||||
case 8 -> migrateToV8();
|
||||
case 9 -> migrateToV9();
|
||||
case 10 -> migrateToV10();
|
||||
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
||||
}
|
||||
}
|
||||
@@ -289,6 +290,25 @@ public final class SqliteDbController {
|
||||
}
|
||||
}
|
||||
|
||||
private void migrateToV10() {
|
||||
try (Connection c = DriverManager.getConnection(jdbcUrl);
|
||||
Statement st = c.createStatement()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
ensureSignedMessagesReencryptedColumn(c, st);
|
||||
setSchemaVersion(c, 10);
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw new RuntimeException("DB migration to v10 failed", e);
|
||||
} finally {
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB migration to v10 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureChat200StateTables(Statement st) throws SQLException {
|
||||
st.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS chat200_state (
|
||||
@@ -416,6 +436,13 @@ public final class SqliteDbController {
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureSignedMessagesReencryptedColumn(Connection c, Statement st) throws SQLException {
|
||||
if (!tableExists(c, "signed_messages_v2")) return;
|
||||
if (!columnExists(c, "signed_messages_v2", "reencrypted_at_ms")) {
|
||||
st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN reencrypted_at_ms INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
}
|
||||
|
||||
private static void dropDmFileTables(Statement st) throws SQLException {
|
||||
st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_login");
|
||||
st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_message");
|
||||
|
||||
@@ -8,7 +8,6 @@ import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public final class SignedMessagesV2DAO {
|
||||
@@ -31,9 +30,10 @@ public final class SignedMessagesV2DAO {
|
||||
String sql = """
|
||||
INSERT OR IGNORE INTO signed_messages_v2 (
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
|
||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindSignedMessage(ps, e);
|
||||
@@ -72,22 +72,18 @@ public final class SignedMessagesV2DAO {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
Long currentIncomingRevision = getRevisionTimeMs(c, incoming.getMessageKey());
|
||||
Long currentOutgoingRevision = getRevisionTimeMs(c, outgoing.getMessageKey());
|
||||
long currentRevision = Math.max(
|
||||
currentIncomingRevision != null ? currentIncomingRevision : Long.MIN_VALUE,
|
||||
currentOutgoingRevision != null ? currentOutgoingRevision : Long.MIN_VALUE
|
||||
);
|
||||
long nextRevision = incoming.getRevisionTimeMs();
|
||||
|
||||
if (currentRevision != Long.MIN_VALUE && nextRevision < currentRevision) {
|
||||
if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) {
|
||||
c.rollback();
|
||||
return false;
|
||||
}
|
||||
if (currentRevision != Long.MIN_VALUE
|
||||
&& nextRevision == currentRevision
|
||||
&& hasSameRawBlock(c, incoming)
|
||||
&& hasSameRawBlock(c, outgoing)) {
|
||||
if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) {
|
||||
c.rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
Long currentRevision = getCurrentContentRevision(c, incoming.getBaseKey());
|
||||
long nextRevision = incoming.getRevisionTimeMs();
|
||||
if (currentRevision != null && nextRevision <= currentRevision) {
|
||||
c.rollback();
|
||||
return false;
|
||||
}
|
||||
@@ -108,13 +104,103 @@ public final class SignedMessagesV2DAO {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean upsertIncomingCopy(SignedMessageV2Entry incoming) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) {
|
||||
c.rollback();
|
||||
return false;
|
||||
}
|
||||
if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) {
|
||||
c.rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
Long currentRevision = getRevisionTimeMs(c, incoming.getMessageKey());
|
||||
long nextRevision = incoming.getRevisionTimeMs();
|
||||
if (currentRevision != null && nextRevision <= currentRevision) {
|
||||
c.rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
upsertMessage(c, incoming);
|
||||
resetDeliveryRows(c, incoming.getMessageKey());
|
||||
c.commit();
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean applyDeleteMessage(SignedMessageV2Entry tombstone) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
if (isBlockedByConversationDelete(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs())) {
|
||||
c.rollback();
|
||||
return false;
|
||||
}
|
||||
if (hasMessageDeleteTombstone(c, tombstone.getBaseKey())) {
|
||||
c.rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
deleteMessageContentAndReceipts(c, tombstone.getBaseKey());
|
||||
upsertMessage(c, tombstone);
|
||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||
|
||||
c.commit();
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean prevAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
Long currentBoundary = getLatestConversationDeleteBoundary(c, tombstone.getFromLogin(), tombstone.getToLogin());
|
||||
if (currentBoundary != null && tombstone.getTimeMs() <= currentBoundary) {
|
||||
c.rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs());
|
||||
upsertMessage(c, tombstone);
|
||||
resetDeliveryRows(c, tombstone.getMessageKey());
|
||||
|
||||
c.commit();
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
throw ex;
|
||||
} finally {
|
||||
c.setAutoCommit(prevAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SignedMessageV2Entry getByMessageKey(String messageKey) throws Exception {
|
||||
try (Connection c = db.getConnection()) {
|
||||
String sql = """
|
||||
SELECT
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
|
||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type
|
||||
FROM signed_messages_v2
|
||||
WHERE message_key = ?
|
||||
""";
|
||||
@@ -180,26 +266,35 @@ public final class SignedMessagesV2DAO {
|
||||
)
|
||||
SELECT m.message_key, ?, 0, NULL, ?
|
||||
FROM signed_messages_v2 m
|
||||
WHERE m.target_login = ? COLLATE NOCASE
|
||||
WHERE (
|
||||
(m.message_type IN (1, 3) AND m.to_login = ? COLLATE NOCASE)
|
||||
OR (m.message_type IN (2, 4) AND m.from_login = ? COLLATE NOCASE)
|
||||
OR (m.message_type IN (5, 6, 7, 8)
|
||||
AND (m.from_login = ? COLLATE NOCASE OR m.to_login = ? COLLATE NOCASE))
|
||||
)
|
||||
""";
|
||||
long now = System.currentTimeMillis();
|
||||
try (PreparedStatement ps = c.prepareStatement(fillSql)) {
|
||||
ps.setString(1, sessionId);
|
||||
ps.setLong(2, now);
|
||||
ps.setString(3, login);
|
||||
ps.setString(4, login);
|
||||
ps.setString(5, login);
|
||||
ps.setString(6, login);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
|
||||
String sql = """
|
||||
SELECT
|
||||
m.message_key, m.base_key, m.target_login, m.from_login, m.to_login,
|
||||
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.raw_block, m.created_at_ms,
|
||||
m.source_api, m.origin_session_id, m.receipt_ref_base_key, m.receipt_ref_type
|
||||
m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms,
|
||||
m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id,
|
||||
m.receipt_ref_base_key, m.receipt_ref_type
|
||||
FROM signed_messages_v2 m
|
||||
JOIN signed_message_session_delivery d
|
||||
ON d.message_key = m.message_key
|
||||
WHERE d.session_id = ? AND d.delivered = 0
|
||||
ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.created_at_ms ASC
|
||||
ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.reencrypted_at_ms ASC, m.created_at_ms ASC
|
||||
""";
|
||||
List<SignedMessageV2Entry> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
@@ -216,9 +311,10 @@ public final class SignedMessagesV2DAO {
|
||||
String sql = """
|
||||
INSERT INTO signed_messages_v2 (
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
|
||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(message_key) DO UPDATE SET
|
||||
base_key = excluded.base_key,
|
||||
target_login = excluded.target_login,
|
||||
@@ -228,6 +324,7 @@ public final class SignedMessagesV2DAO {
|
||||
nonce = excluded.nonce,
|
||||
message_type = excluded.message_type,
|
||||
revision_time_ms = excluded.revision_time_ms,
|
||||
reencrypted_at_ms = excluded.reencrypted_at_ms,
|
||||
raw_block = excluded.raw_block,
|
||||
created_at_ms = excluded.created_at_ms,
|
||||
source_api = excluded.source_api,
|
||||
@@ -252,17 +349,122 @@ public final class SignedMessagesV2DAO {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasSameRawBlock(Connection c, SignedMessageV2Entry entry) throws SQLException {
|
||||
String sql = "SELECT raw_block FROM signed_messages_v2 WHERE message_key = ? LIMIT 1";
|
||||
private Long getCurrentContentRevision(Connection c, String baseKey) throws SQLException {
|
||||
String sql = """
|
||||
SELECT MAX(revision_time_ms)
|
||||
FROM signed_messages_v2
|
||||
WHERE base_key = ?
|
||||
AND message_type IN (1, 2)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, entry.getMessageKey());
|
||||
ps.setString(1, baseKey);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return false;
|
||||
return Arrays.equals(rs.getBytes(1), entry.getRawBlock());
|
||||
if (!rs.next()) return null;
|
||||
long value = rs.getLong(1);
|
||||
return rs.wasNull() ? null : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasMessageDeleteTombstone(Connection c, String baseKey) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM signed_messages_v2
|
||||
WHERE base_key = ?
|
||||
AND message_type IN (5, 6)
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, baseKey);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBlockedByConversationDelete(Connection c, String fromLogin, String toLogin, long timeMs) throws SQLException {
|
||||
Long boundary = getLatestConversationDeleteBoundary(c, fromLogin, toLogin);
|
||||
return boundary != null && timeMs < boundary;
|
||||
}
|
||||
|
||||
private Long getLatestConversationDeleteBoundary(Connection c, String fromLogin, String toLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT MAX(time_ms)
|
||||
FROM signed_messages_v2
|
||||
WHERE message_type IN (7, 8)
|
||||
AND (
|
||||
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, fromLogin);
|
||||
ps.setString(2, toLogin);
|
||||
ps.setString(3, toLogin);
|
||||
ps.setString(4, fromLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
long value = rs.getLong(1);
|
||||
return rs.wasNull() ? null : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteMessageContentAndReceipts(Connection c, String baseKey) throws SQLException {
|
||||
deleteDeliveryRowsByMessageSelection(c, """
|
||||
SELECT message_key
|
||||
FROM signed_messages_v2
|
||||
WHERE (base_key = ? AND message_type IN (1, 2))
|
||||
OR (receipt_ref_base_key = ? AND message_type IN (3, 4))
|
||||
""", baseKey, baseKey);
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
DELETE FROM signed_messages_v2
|
||||
WHERE (base_key = ? AND message_type IN (1, 2))
|
||||
OR (receipt_ref_base_key = ? AND message_type IN (3, 4))
|
||||
""")) {
|
||||
ps.setString(1, baseKey);
|
||||
ps.setString(2, baseKey);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteConversationHistoryBefore(Connection c, String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException {
|
||||
deleteDeliveryRowsByMessageSelection(c, """
|
||||
SELECT message_key
|
||||
FROM signed_messages_v2
|
||||
WHERE time_ms < ?
|
||||
AND (
|
||||
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
)
|
||||
""", boundaryTimeMs, fromLogin, toLogin, toLogin, fromLogin);
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
DELETE FROM signed_messages_v2
|
||||
WHERE time_ms < ?
|
||||
AND (
|
||||
(from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE)
|
||||
)
|
||||
""")) {
|
||||
ps.setLong(1, boundaryTimeMs);
|
||||
ps.setString(2, fromLogin);
|
||||
ps.setString(3, toLogin);
|
||||
ps.setString(4, toLogin);
|
||||
ps.setString(5, fromLogin);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteDeliveryRowsByMessageSelection(Connection c, String messageKeySelectSql, Object... bindValues) throws SQLException {
|
||||
String sql = "DELETE FROM signed_message_session_delivery WHERE message_key IN (" + messageKeySelectSql + ")";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindObjects(ps, bindValues);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void resetDeliveryRows(Connection c, String messageKey) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE signed_message_session_delivery
|
||||
@@ -278,9 +480,10 @@ public final class SignedMessagesV2DAO {
|
||||
String sql = """
|
||||
INSERT INTO signed_messages_v2 (
|
||||
message_key, base_key, target_login, from_login, to_login,
|
||||
time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms,
|
||||
source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms,
|
||||
raw_block, created_at_ms, source_api, origin_session_id,
|
||||
receipt_ref_base_key, receipt_ref_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindSignedMessage(ps, e);
|
||||
@@ -298,13 +501,30 @@ public final class SignedMessagesV2DAO {
|
||||
ps.setLong(7, e.getNonce());
|
||||
ps.setInt(8, e.getMessageType());
|
||||
ps.setLong(9, e.getRevisionTimeMs());
|
||||
ps.setBytes(10, e.getRawBlock());
|
||||
ps.setLong(11, e.getCreatedAtMs());
|
||||
ps.setString(12, e.getSourceApi());
|
||||
ps.setString(13, e.getOriginSessionId());
|
||||
ps.setString(14, e.getReceiptRefBaseKey());
|
||||
if (e.getReceiptRefType() == null) ps.setObject(15, null);
|
||||
else ps.setInt(15, e.getReceiptRefType());
|
||||
ps.setLong(10, e.getReencryptedAtMs());
|
||||
ps.setBytes(11, e.getRawBlock());
|
||||
ps.setLong(12, e.getCreatedAtMs());
|
||||
ps.setString(13, e.getSourceApi());
|
||||
ps.setString(14, e.getOriginSessionId());
|
||||
ps.setString(15, e.getReceiptRefBaseKey());
|
||||
if (e.getReceiptRefType() == null) ps.setObject(16, null);
|
||||
else ps.setInt(16, e.getReceiptRefType());
|
||||
}
|
||||
|
||||
private void bindObjects(PreparedStatement ps, Object... bindValues) throws SQLException {
|
||||
for (int i = 0; i < bindValues.length; i++) {
|
||||
Object value = bindValues[i];
|
||||
int param = i + 1;
|
||||
if (value instanceof String s) {
|
||||
ps.setString(param, s);
|
||||
} else if (value instanceof Long l) {
|
||||
ps.setLong(param, l);
|
||||
} else if (value instanceof Integer n) {
|
||||
ps.setInt(param, n);
|
||||
} else {
|
||||
ps.setObject(param, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isConstraintViolation(SQLException ex) {
|
||||
@@ -323,6 +543,7 @@ public final class SignedMessagesV2DAO {
|
||||
e.setNonce(rs.getLong("nonce"));
|
||||
e.setMessageType(rs.getInt("message_type"));
|
||||
e.setRevisionTimeMs(rs.getLong("revision_time_ms"));
|
||||
e.setReencryptedAtMs(rs.getLong("reencrypted_at_ms"));
|
||||
e.setRawBlock(rs.getBytes("raw_block"));
|
||||
e.setCreatedAtMs(rs.getLong("created_at_ms"));
|
||||
e.setSourceApi(rs.getString("source_api"));
|
||||
|
||||
@@ -10,6 +10,7 @@ public class SignedMessageV2Entry {
|
||||
private long nonce;
|
||||
private int messageType;
|
||||
private long revisionTimeMs;
|
||||
private long reencryptedAtMs;
|
||||
private byte[] rawBlock;
|
||||
private long createdAtMs;
|
||||
private String sourceApi;
|
||||
@@ -35,6 +36,8 @@ public class SignedMessageV2Entry {
|
||||
public void setMessageType(int messageType) { this.messageType = messageType; }
|
||||
public long getRevisionTimeMs() { return revisionTimeMs; }
|
||||
public void setRevisionTimeMs(long revisionTimeMs) { this.revisionTimeMs = revisionTimeMs; }
|
||||
public long getReencryptedAtMs() { return reencryptedAtMs; }
|
||||
public void setReencryptedAtMs(long reencryptedAtMs) { this.reencryptedAtMs = reencryptedAtMs; }
|
||||
public byte[] getRawBlock() { return rawBlock; }
|
||||
public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
|
||||
+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_CallInviteBroadcast_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
|
||||
@@ -98,6 +100,8 @@ import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
||||
@@ -195,6 +199,8 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()),
|
||||
Map.entry("ReceiveOutcomingMessage", new Net_SendMessagePair_Handler()),
|
||||
Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()),
|
||||
Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()),
|
||||
Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()),
|
||||
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
||||
Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()),
|
||||
@@ -274,6 +280,8 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("SendMessagePair", Net_SendMessagePair_Request.class),
|
||||
Map.entry("ReceiveOutcomingMessage", Net_SendMessagePair_Request.class),
|
||||
Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class),
|
||||
Map.entry("DeleteMessage", Net_DeleteMessage_Request.class),
|
||||
Map.entry("DeleteConversation", Net_DeleteConversation_Request.class),
|
||||
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
||||
Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class),
|
||||
|
||||
+40
-2
@@ -16,8 +16,10 @@ import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Lazy-import пользователя из Solana PDA в локальную БД сервера.
|
||||
@@ -99,6 +101,28 @@ public final class SolanaUserPdaImportService {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
public static List<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 {
|
||||
byte[] raw = fetchRawUserPda(login);
|
||||
if (raw == null) return null;
|
||||
@@ -184,6 +208,7 @@ public final class SolanaUserPdaImportService {
|
||||
byte[] clientKey32 = null;
|
||||
long paidLimitBytes = 0L;
|
||||
List<ParsedSessionRecord> sessions = new ArrayList<>();
|
||||
List<String> accessServers = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < blocksCount; i++) {
|
||||
int blockType = u8(raw, c++);
|
||||
@@ -239,7 +264,9 @@ public final class SolanaUserPdaImportService {
|
||||
int accessCount = u8(raw, c++);
|
||||
for (int j = 0; j < accessCount; j++) {
|
||||
int n = u8(raw, c++);
|
||||
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||
c += n;
|
||||
accessServers.add(normalizeLogin(accessServerLogin));
|
||||
}
|
||||
} else if (blockType == 50) {
|
||||
int sessionsMode = u8(raw, c++);
|
||||
@@ -277,7 +304,8 @@ public final class SolanaUserPdaImportService {
|
||||
Base64.getEncoder().encodeToString(blockchainKey32),
|
||||
Base64.getEncoder().encodeToString(clientKey32),
|
||||
paidLimitBytes,
|
||||
sessions
|
||||
sessions,
|
||||
accessServers
|
||||
);
|
||||
}
|
||||
|
||||
@@ -438,13 +466,18 @@ public final class SolanaUserPdaImportService {
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
private static String safe(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private record ParsedSolanaUser(
|
||||
String login,
|
||||
String blockchainName,
|
||||
String blockchainKeyB64,
|
||||
String clientKeyB64,
|
||||
long paidLimitBytes,
|
||||
List<ParsedSessionRecord> sessions
|
||||
List<ParsedSessionRecord> sessions,
|
||||
List<String> accessServers
|
||||
) {}
|
||||
|
||||
private record ParsedSessionRecord(
|
||||
@@ -479,4 +512,9 @@ public final class SolanaUserPdaImportService {
|
||||
String serverAddress,
|
||||
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) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат входящего блока");
|
||||
}
|
||||
if (!incoming.isIncomingType()) {
|
||||
if (incoming.messageType != SignedMessageBlock.TYPE_INCOMING_TEXT
|
||||
&& incoming.messageType != SignedMessageBlock.TYPE_READ_INCOMING) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_MESSAGE_TYPE", "API принимает только входящие типы 1/3");
|
||||
}
|
||||
|
||||
@@ -44,10 +45,12 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
|
||||
}
|
||||
|
||||
boolean inserted = SignedMessagesV2DAO.getInstance().insertIfAbsent(entry);
|
||||
boolean inserted = incoming.isContentType()
|
||||
? SignedMessagesV2DAO.getInstance().upsertIncomingCopy(entry)
|
||||
: SignedMessagesV2DAO.getInstance().insertIfAbsent(entry);
|
||||
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
||||
if (inserted) {
|
||||
counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null);
|
||||
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
|
||||
}
|
||||
|
||||
Net_ReceiveIncomingMessage_Response resp = new Net_ReceiveIncomingMessage_Response();
|
||||
|
||||
+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.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import server.sync.DmFederationService;
|
||||
import shine.db.dao.SignedMessagesV2DAO;
|
||||
import shine.db.entities.SignedMessageV2Entry;
|
||||
|
||||
@@ -41,7 +42,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
||||
SignedMessageV2Entry incomingEntry;
|
||||
SignedMessageV2Entry outgoingEntry;
|
||||
try {
|
||||
String sourceApi = "ReceiveOutcomingMessage";
|
||||
String sourceApi = "SendMessagePair";
|
||||
String originSessionId = (ctx != null && !isBlank(ctx.getSessionId())) ? ctx.getSessionId() : null;
|
||||
incomingEntry = SignedMessagesCore.toEntry(incoming, sourceApi, originSessionId);
|
||||
outgoingEntry = SignedMessagesCore.toEntry(outgoing, sourceApi, originSessionId);
|
||||
@@ -60,7 +61,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
||||
|
||||
SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||
if (pairInserted) {
|
||||
inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, incoming);
|
||||
inCounters = SignedMessagesRealtime.deliverToRelevantSessions(incomingEntry, incoming);
|
||||
}
|
||||
|
||||
String excludeSessionId = null;
|
||||
@@ -69,7 +70,16 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
||||
}
|
||||
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||
if (pairInserted) {
|
||||
outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, outgoing, excludeSessionId);
|
||||
outCounters = SignedMessagesRealtime.deliverToRelevantSessions(outgoingEntry, outgoing, excludeSessionId);
|
||||
}
|
||||
|
||||
if (pairInserted && ctx != null && ctx.isAuthenticatedUser()) {
|
||||
DmFederationService.fanOutPair(
|
||||
incoming.fromLogin,
|
||||
incoming.toLogin,
|
||||
req.getIncomingBlobB64().trim(),
|
||||
req.getOutgoingBlobB64().trim()
|
||||
);
|
||||
}
|
||||
|
||||
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
|
||||
|
||||
+3
-9
@@ -9,18 +9,16 @@ final class ReadReceiptPayload {
|
||||
final String refFromLogin;
|
||||
final long refTimeMs;
|
||||
final long refNonce;
|
||||
final int refType;
|
||||
|
||||
private ReadReceiptPayload(String refToLogin, String refFromLogin, long refTimeMs, long refNonce, int refType) {
|
||||
private ReadReceiptPayload(String refToLogin, String refFromLogin, long refTimeMs, long refNonce) {
|
||||
this.refToLogin = refToLogin;
|
||||
this.refFromLogin = refFromLogin;
|
||||
this.refTimeMs = refTimeMs;
|
||||
this.refNonce = refNonce;
|
||||
this.refType = refType;
|
||||
}
|
||||
|
||||
static ReadReceiptPayload parse(byte[] payload) {
|
||||
if (payload == null || payload.length < 1 + 1 + 8 + 4 + 2) {
|
||||
if (payload == null || payload.length < 1 + 1 + 8 + 4) {
|
||||
throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN");
|
||||
}
|
||||
ByteBuffer bb = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN);
|
||||
@@ -29,14 +27,10 @@ final class ReadReceiptPayload {
|
||||
long refTimeMs = bb.getLong();
|
||||
if (refTimeMs < 0) throw new IllegalArgumentException("BAD_RECEIPT_TIME");
|
||||
long refNonce = Integer.toUnsignedLong(bb.getInt());
|
||||
int refType = Short.toUnsignedInt(bb.getShort());
|
||||
if (refType < SignedMessageBlock.TYPE_INCOMING_TEXT || refType > SignedMessageBlock.TYPE_READ_OUTGOING_COPY) {
|
||||
throw new IllegalArgumentException("BAD_RECEIPT_REF_TYPE");
|
||||
}
|
||||
if (bb.hasRemaining()) {
|
||||
throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN");
|
||||
}
|
||||
return new ReadReceiptPayload(refTo, refFrom, refTimeMs, refNonce, refType);
|
||||
return new ReadReceiptPayload(refTo, refFrom, refTimeMs, refNonce);
|
||||
}
|
||||
|
||||
String refBaseKey() {
|
||||
|
||||
+83
-102
@@ -6,12 +6,16 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
final class SignedMessageBlock {
|
||||
static final byte[] LEGACY_PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII);
|
||||
static final byte[] V1_PREFIX = "SHiNE_DM".getBytes(StandardCharsets.US_ASCII);
|
||||
static final byte[] PREFIX = "SHiNE_DM".getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
static final int TYPE_INCOMING_TEXT = 1;
|
||||
static final int TYPE_OUTGOING_COPY = 2;
|
||||
static final int TYPE_READ_INCOMING = 3;
|
||||
static final int TYPE_READ_OUTGOING_COPY = 4;
|
||||
static final int TYPE_MESSAGE_DELETED_BY_SENDER = 5;
|
||||
static final int TYPE_MESSAGE_DELETED_BY_RECIPIENT = 6;
|
||||
static final int TYPE_CONVERSATION_DELETED_BY_SENDER = 7;
|
||||
static final int TYPE_CONVERSATION_DELETED_BY_RECIPIENT = 8;
|
||||
|
||||
final String toLogin;
|
||||
final String fromLogin;
|
||||
@@ -19,14 +23,15 @@ final class SignedMessageBlock {
|
||||
final long nonce;
|
||||
final int messageType;
|
||||
final long revisionTimeMs;
|
||||
final long reencryptedAtMs;
|
||||
final int formatVersionMajor;
|
||||
final int formatVersionMinor;
|
||||
final byte[] payloadBytes;
|
||||
final byte[] encryptedBodyBytes;
|
||||
final byte[] bodyBytes;
|
||||
final EncryptedBodyPayload encryptedBody;
|
||||
final ReadReceiptPayload readReceiptBody;
|
||||
final byte[] signedBody;
|
||||
final byte[] signature64;
|
||||
final byte[] rawPacket;
|
||||
final boolean legacyFormat;
|
||||
|
||||
private SignedMessageBlock(
|
||||
String toLogin,
|
||||
@@ -35,14 +40,15 @@ final class SignedMessageBlock {
|
||||
long nonce,
|
||||
int messageType,
|
||||
long revisionTimeMs,
|
||||
long reencryptedAtMs,
|
||||
int formatVersionMajor,
|
||||
int formatVersionMinor,
|
||||
byte[] payloadBytes,
|
||||
byte[] encryptedBodyBytes,
|
||||
byte[] bodyBytes,
|
||||
EncryptedBodyPayload encryptedBody,
|
||||
ReadReceiptPayload readReceiptBody,
|
||||
byte[] signedBody,
|
||||
byte[] signature64,
|
||||
byte[] rawPacket,
|
||||
boolean legacyFormat
|
||||
byte[] rawPacket
|
||||
) {
|
||||
this.toLogin = toLogin;
|
||||
this.fromLogin = fromLogin;
|
||||
@@ -50,85 +56,27 @@ final class SignedMessageBlock {
|
||||
this.nonce = nonce;
|
||||
this.messageType = messageType;
|
||||
this.revisionTimeMs = revisionTimeMs;
|
||||
this.reencryptedAtMs = reencryptedAtMs;
|
||||
this.formatVersionMajor = formatVersionMajor;
|
||||
this.formatVersionMinor = formatVersionMinor;
|
||||
this.payloadBytes = payloadBytes;
|
||||
this.encryptedBodyBytes = encryptedBodyBytes;
|
||||
this.bodyBytes = bodyBytes;
|
||||
this.encryptedBody = encryptedBody;
|
||||
this.readReceiptBody = readReceiptBody;
|
||||
this.signedBody = signedBody;
|
||||
this.signature64 = signature64;
|
||||
this.rawPacket = rawPacket;
|
||||
this.legacyFormat = legacyFormat;
|
||||
}
|
||||
|
||||
static SignedMessageBlock parse(byte[] raw, int maxEncryptedBodyBytes) {
|
||||
if (raw == null || raw.length < 64) {
|
||||
static SignedMessageBlock parse(byte[] raw, int maxBodyBytes) {
|
||||
if (raw == null || raw.length < PREFIX.length + 2 + 1 + 1 + 8 + 4 + 1 + 8 + 8 + 4 + 64) {
|
||||
throw new IllegalArgumentException("BAD_LEN");
|
||||
}
|
||||
if (!startsWith(raw, PREFIX)) {
|
||||
throw new IllegalArgumentException("BAD_PREFIX");
|
||||
}
|
||||
|
||||
if (startsWith(raw, LEGACY_PREFIX)) {
|
||||
return parseLegacy(raw, maxEncryptedBodyBytes);
|
||||
}
|
||||
if (startsWith(raw, V1_PREFIX)) {
|
||||
return parseV1(raw, maxEncryptedBodyBytes);
|
||||
}
|
||||
throw new IllegalArgumentException("BAD_PREFIX");
|
||||
}
|
||||
|
||||
private static SignedMessageBlock parseLegacy(byte[] raw, int maxPayloadBytes) {
|
||||
if (raw.length < LEGACY_PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) {
|
||||
throw new IllegalArgumentException("BAD_LEN");
|
||||
}
|
||||
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.position(LEGACY_PREFIX.length);
|
||||
|
||||
String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN");
|
||||
String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN");
|
||||
|
||||
long timeMs = bb.getLong();
|
||||
if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME");
|
||||
|
||||
long nonce = Integer.toUnsignedLong(bb.getInt());
|
||||
int messageType = Short.toUnsignedInt(bb.getShort());
|
||||
ensureMessageType(messageType);
|
||||
|
||||
int payloadLen = Short.toUnsignedInt(bb.getShort());
|
||||
if (payloadLen < 1 || payloadLen > maxPayloadBytes) {
|
||||
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
||||
}
|
||||
if (bb.remaining() != payloadLen + 64) {
|
||||
throw new IllegalArgumentException("BAD_LEN");
|
||||
}
|
||||
|
||||
byte[] payload = new byte[payloadLen];
|
||||
bb.get(payload);
|
||||
byte[] signature64 = new byte[64];
|
||||
bb.get(signature64);
|
||||
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
|
||||
|
||||
return new SignedMessageBlock(
|
||||
toLogin,
|
||||
fromLogin,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType,
|
||||
0L,
|
||||
2,
|
||||
0,
|
||||
payload,
|
||||
payload,
|
||||
signedBody,
|
||||
signature64,
|
||||
raw,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private static SignedMessageBlock parseV1(byte[] raw, int maxEncryptedBodyBytes) {
|
||||
if (raw.length < V1_PREFIX.length + 2 + 1 + 1 + 8 + 4 + 2 + 8 + 1 + 4 + 64) {
|
||||
throw new IllegalArgumentException("BAD_LEN");
|
||||
}
|
||||
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
|
||||
bb.position(V1_PREFIX.length);
|
||||
bb.position(PREFIX.length);
|
||||
|
||||
int major = Byte.toUnsignedInt(bb.get());
|
||||
int minor = Byte.toUnsignedInt(bb.get());
|
||||
@@ -141,32 +89,55 @@ final class SignedMessageBlock {
|
||||
long timeMs = bb.getLong();
|
||||
if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME");
|
||||
long nonce = Integer.toUnsignedLong(bb.getInt());
|
||||
int messageType = Short.toUnsignedInt(bb.getShort());
|
||||
int messageType = Byte.toUnsignedInt(bb.get());
|
||||
ensureMessageType(messageType);
|
||||
long revisionTimeMs = bb.getLong();
|
||||
if (revisionTimeMs < 0) throw new IllegalArgumentException("BAD_REVISION_TIME");
|
||||
long reencryptedAtMs = bb.getLong();
|
||||
if (reencryptedAtMs < 0) throw new IllegalArgumentException("BAD_REENCRYPTED_TIME");
|
||||
|
||||
int attachmentsCount = Byte.toUnsignedInt(bb.get());
|
||||
if (attachmentsCount != 0) {
|
||||
throw new IllegalArgumentException("ATTACHMENTS_DISABLED");
|
||||
}
|
||||
|
||||
if (bb.remaining() < 4 + 64) {
|
||||
throw new IllegalArgumentException("BAD_LEN");
|
||||
}
|
||||
long encryptedBodyLen = Integer.toUnsignedLong(bb.getInt());
|
||||
if (encryptedBodyLen > maxEncryptedBodyBytes) {
|
||||
long bodyLen = Integer.toUnsignedLong(bb.getInt());
|
||||
if (bodyLen > maxBodyBytes) {
|
||||
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
||||
}
|
||||
if (bb.remaining() != encryptedBodyLen + 64) {
|
||||
if (bb.remaining() != bodyLen + 64) {
|
||||
throw new IllegalArgumentException("BAD_LEN");
|
||||
}
|
||||
byte[] encryptedBody = new byte[(int) encryptedBodyLen];
|
||||
bb.get(encryptedBody);
|
||||
|
||||
byte[] bodyBytes = new byte[(int) bodyLen];
|
||||
bb.get(bodyBytes);
|
||||
byte[] signature64 = new byte[64];
|
||||
bb.get(signature64);
|
||||
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
|
||||
|
||||
EncryptedBodyPayload encryptedBody = null;
|
||||
ReadReceiptPayload readReceiptBody = null;
|
||||
|
||||
if (messageType == TYPE_INCOMING_TEXT || messageType == TYPE_OUTGOING_COPY) {
|
||||
if (bodyBytes.length == 0) throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
||||
encryptedBody = EncryptedBodyPayload.parse(bodyBytes, maxBodyBytes);
|
||||
if (revisionTimeMs == 0 && reencryptedAtMs != 0) {
|
||||
throw new IllegalArgumentException("BAD_REENCRYPTED_TIME");
|
||||
}
|
||||
if (reencryptedAtMs > 0 && revisionTimeMs == 0) {
|
||||
throw new IllegalArgumentException("BAD_REENCRYPTED_TIME");
|
||||
}
|
||||
} else if (messageType == TYPE_READ_INCOMING || messageType == TYPE_READ_OUTGOING_COPY) {
|
||||
if (bodyBytes.length == 0) throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN");
|
||||
if (revisionTimeMs != 0 || reencryptedAtMs != 0) {
|
||||
throw new IllegalArgumentException("BAD_RECEIPT_REVISION");
|
||||
}
|
||||
readReceiptBody = ReadReceiptPayload.parse(bodyBytes);
|
||||
} else if (messageType == TYPE_MESSAGE_DELETED_BY_SENDER || messageType == TYPE_MESSAGE_DELETED_BY_RECIPIENT) {
|
||||
if (bodyBytes.length != 0) throw new IllegalArgumentException("BAD_DELETE_BODY");
|
||||
if (revisionTimeMs <= 0) throw new IllegalArgumentException("BAD_REVISION_TIME");
|
||||
if (reencryptedAtMs != 0) throw new IllegalArgumentException("BAD_REENCRYPTED_TIME");
|
||||
} else if (messageType == TYPE_CONVERSATION_DELETED_BY_SENDER || messageType == TYPE_CONVERSATION_DELETED_BY_RECIPIENT) {
|
||||
if (bodyBytes.length != 0) throw new IllegalArgumentException("BAD_DELETE_BODY");
|
||||
if (revisionTimeMs != 0) throw new IllegalArgumentException("BAD_REVISION_TIME");
|
||||
if (reencryptedAtMs != 0) throw new IllegalArgumentException("BAD_REENCRYPTED_TIME");
|
||||
}
|
||||
|
||||
return new SignedMessageBlock(
|
||||
toLogin,
|
||||
fromLogin,
|
||||
@@ -174,23 +145,24 @@ final class SignedMessageBlock {
|
||||
nonce,
|
||||
messageType,
|
||||
revisionTimeMs,
|
||||
reencryptedAtMs,
|
||||
major,
|
||||
minor,
|
||||
bodyBytes,
|
||||
encryptedBody,
|
||||
encryptedBody,
|
||||
readReceiptBody,
|
||||
signedBody,
|
||||
signature64,
|
||||
raw,
|
||||
false
|
||||
raw
|
||||
);
|
||||
}
|
||||
|
||||
boolean isIncomingType() {
|
||||
return messageType == TYPE_INCOMING_TEXT || messageType == TYPE_READ_INCOMING;
|
||||
boolean isIncomingCopyType() {
|
||||
return messageType == TYPE_INCOMING_TEXT;
|
||||
}
|
||||
|
||||
boolean isOutgoingCopyType() {
|
||||
return messageType == TYPE_OUTGOING_COPY || messageType == TYPE_READ_OUTGOING_COPY;
|
||||
return messageType == TYPE_OUTGOING_COPY;
|
||||
}
|
||||
|
||||
boolean isContentType() {
|
||||
@@ -201,16 +173,25 @@ final class SignedMessageBlock {
|
||||
return messageType == TYPE_READ_INCOMING || messageType == TYPE_READ_OUTGOING_COPY;
|
||||
}
|
||||
|
||||
boolean isDeletedContent() {
|
||||
return isContentType() && !legacyFormat && encryptedBodyBytes.length == 0;
|
||||
boolean isMessageDeleteType() {
|
||||
return messageType == TYPE_MESSAGE_DELETED_BY_SENDER || messageType == TYPE_MESSAGE_DELETED_BY_RECIPIENT;
|
||||
}
|
||||
|
||||
String targetLogin() {
|
||||
return isIncomingType() ? toLogin : fromLogin;
|
||||
boolean isConversationDeleteType() {
|
||||
return messageType == TYPE_CONVERSATION_DELETED_BY_SENDER || messageType == TYPE_CONVERSATION_DELETED_BY_RECIPIENT;
|
||||
}
|
||||
|
||||
boolean isDeleteType() {
|
||||
return isMessageDeleteType() || isConversationDeleteType();
|
||||
}
|
||||
|
||||
boolean isSignedByRecipient() {
|
||||
return messageType == TYPE_MESSAGE_DELETED_BY_RECIPIENT
|
||||
|| messageType == TYPE_CONVERSATION_DELETED_BY_RECIPIENT;
|
||||
}
|
||||
|
||||
private static void ensureMessageType(int messageType) {
|
||||
if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_READ_OUTGOING_COPY) {
|
||||
if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_CONVERSATION_DELETED_BY_RECIPIENT) {
|
||||
throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
|
||||
}
|
||||
}
|
||||
|
||||
+49
-41
@@ -5,26 +5,24 @@ import shine.db.entities.SignedMessageV2Entry;
|
||||
import shine.db.entities.SolanaUserEntry;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
final class SignedMessagesCore {
|
||||
private static final int MAX_ENCRYPTED_BODY_BYTES = 16384;
|
||||
private static final int MAX_BODY_BYTES = 16384;
|
||||
|
||||
private SignedMessagesCore() {}
|
||||
|
||||
static SignedMessageBlock parseFromB64(String blobB64) {
|
||||
try {
|
||||
byte[] raw = Base64.getDecoder().decode(blobB64.trim());
|
||||
return SignedMessageBlock.parse(raw, MAX_ENCRYPTED_BODY_BYTES);
|
||||
return SignedMessageBlock.parse(raw, MAX_BODY_BYTES);
|
||||
} catch (IllegalArgumentException e) {
|
||||
String code = e.getMessage();
|
||||
if (code == null || code.isBlank()) {
|
||||
throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
|
||||
}
|
||||
switch (code) {
|
||||
case "ATTACHMENTS_DISABLED",
|
||||
"BAD_PREFIX",
|
||||
case "BAD_PREFIX",
|
||||
"BAD_LEN",
|
||||
"BAD_TO_LOGIN",
|
||||
"BAD_FROM_LOGIN",
|
||||
@@ -32,7 +30,16 @@ final class SignedMessagesCore {
|
||||
"BAD_MESSAGE_TYPE",
|
||||
"BAD_MESSAGE_LEN",
|
||||
"BAD_FORMAT_VERSION",
|
||||
"BAD_REVISION_TIME" -> throw e;
|
||||
"BAD_REVISION_TIME",
|
||||
"BAD_REENCRYPTED_TIME",
|
||||
"BAD_DELETE_BODY",
|
||||
"BAD_RECEIPT_PAYLOAD_LEN",
|
||||
"BAD_RECEIPT_TO_LOGIN",
|
||||
"BAD_RECEIPT_FROM_LOGIN",
|
||||
"BAD_RECEIPT_TIME",
|
||||
"BAD_RECEIPT_REVISION",
|
||||
"BAD_ENCRYPTED_BODY",
|
||||
"BAD_CRYPTO_METHOD" -> throw e;
|
||||
default -> throw new IllegalArgumentException("BAD_BLOCK_FORMAT");
|
||||
}
|
||||
}
|
||||
@@ -44,7 +51,10 @@ final class SignedMessagesCore {
|
||||
if (from == null || to == null) {
|
||||
throw new IllegalArgumentException("USER_NOT_FOUND");
|
||||
}
|
||||
byte[] pubKey32 = Ed25519Util.keyFromBase64(from.getClientKey());
|
||||
|
||||
String signerLogin = block.isSignedByRecipient() ? block.toLogin : block.fromLogin;
|
||||
SolanaUserEntry signer = signerLogin.equalsIgnoreCase(block.fromLogin) ? from : to;
|
||||
byte[] pubKey32 = Ed25519Util.keyFromBase64(signer.getClientKey());
|
||||
if (!Ed25519Util.verify(block.signedBody, block.signature64, pubKey32)) {
|
||||
throw new IllegalArgumentException("BAD_SIGNATURE");
|
||||
}
|
||||
@@ -57,38 +67,28 @@ final class SignedMessagesCore {
|
||||
if (!incoming.fromLogin.equalsIgnoreCase(outgoing.fromLogin)) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
||||
if (incoming.timeMs != outgoing.timeMs) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
||||
if (incoming.nonce != outgoing.nonce) throw new IllegalArgumentException("BAD_PAIR_KEYS");
|
||||
|
||||
if (incoming.isReadReceiptType()) {
|
||||
ReadReceiptPayload inRef = ReadReceiptPayload.parse(incoming.payloadBytes);
|
||||
ReadReceiptPayload outRef = ReadReceiptPayload.parse(outgoing.payloadBytes);
|
||||
if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin)
|
||||
|| !inRef.refFromLogin.equalsIgnoreCase(outRef.refFromLogin)
|
||||
|| inRef.refTimeMs != outRef.refTimeMs
|
||||
|| inRef.refNonce != outRef.refNonce
|
||||
|| inRef.refType != outRef.refType) {
|
||||
throw new IllegalArgumentException("BAD_RECEIPT_REF");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (incoming.legacyFormat || outgoing.legacyFormat) {
|
||||
throw new IllegalArgumentException("BAD_CONTENT_FORMAT");
|
||||
}
|
||||
if (incoming.revisionTimeMs != outgoing.revisionTimeMs) {
|
||||
throw new IllegalArgumentException("BAD_REVISION_TIME");
|
||||
}
|
||||
if (incoming.revisionTimeMs != outgoing.revisionTimeMs) throw new IllegalArgumentException("BAD_REVISION_TIME");
|
||||
if (incoming.reencryptedAtMs != outgoing.reencryptedAtMs) throw new IllegalArgumentException("BAD_REENCRYPTED_TIME");
|
||||
if (incoming.formatVersionMajor != outgoing.formatVersionMajor
|
||||
|| incoming.formatVersionMinor != outgoing.formatVersionMinor) {
|
||||
throw new IllegalArgumentException("BAD_FORMAT_VERSION");
|
||||
}
|
||||
|
||||
if (incoming.encryptedBodyBytes.length != outgoing.encryptedBodyBytes.length) {
|
||||
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
||||
}
|
||||
for (int i = 0; i < incoming.encryptedBodyBytes.length; i++) {
|
||||
if (incoming.encryptedBodyBytes[i] != outgoing.encryptedBodyBytes[i]) {
|
||||
throw new IllegalArgumentException("BAD_ENCRYPTED_BODY");
|
||||
if (incoming.isReadReceiptType()) {
|
||||
if (!outgoing.isReadReceiptType()) throw new IllegalArgumentException("BAD_PAIR_TYPES");
|
||||
ReadReceiptPayload inRef = incoming.readReceiptBody;
|
||||
ReadReceiptPayload outRef = outgoing.readReceiptBody;
|
||||
if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin)
|
||||
|| !inRef.refFromLogin.equalsIgnoreCase(outRef.refFromLogin)
|
||||
|| inRef.refTimeMs != outRef.refTimeMs
|
||||
|| inRef.refNonce != outRef.refNonce) {
|
||||
throw new IllegalArgumentException("BAD_RECEIPT_REF");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!incoming.isContentType() || !outgoing.isContentType()) {
|
||||
throw new IllegalArgumentException("BAD_PAIR_TYPES");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,32 +99,40 @@ final class SignedMessagesCore {
|
||||
SignedMessageV2Entry entry = new SignedMessageV2Entry();
|
||||
entry.setMessageKey(messageKey);
|
||||
entry.setBaseKey(baseKey);
|
||||
entry.setTargetLogin(block.targetLogin());
|
||||
entry.setTargetLogin(primaryTargetLogin(block));
|
||||
entry.setFromLogin(block.fromLogin);
|
||||
entry.setToLogin(block.toLogin);
|
||||
entry.setTimeMs(block.timeMs);
|
||||
entry.setNonce(block.nonce);
|
||||
entry.setMessageType(block.messageType);
|
||||
entry.setRevisionTimeMs(block.revisionTimeMs);
|
||||
entry.setReencryptedAtMs(block.reencryptedAtMs);
|
||||
entry.setRawBlock(block.rawPacket);
|
||||
entry.setCreatedAtMs(System.currentTimeMillis());
|
||||
entry.setSourceApi(sourceApi);
|
||||
entry.setOriginSessionId(originSessionId);
|
||||
|
||||
if (block.messageType == SignedMessageBlock.TYPE_READ_INCOMING
|
||||
|| block.messageType == SignedMessageBlock.TYPE_READ_OUTGOING_COPY) {
|
||||
ReadReceiptPayload ref = ReadReceiptPayload.parse(block.payloadBytes);
|
||||
if (block.isReadReceiptType()) {
|
||||
ReadReceiptPayload ref = block.readReceiptBody;
|
||||
entry.setReceiptRefBaseKey(ref.refBaseKey());
|
||||
entry.setReceiptRefType(ref.refType);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
static String previewTextForPush(SignedMessageBlock block) {
|
||||
if (!block.isContentType() || block.encryptedBodyBytes == null || block.encryptedBodyBytes.length == 0) {
|
||||
return "";
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String primaryTargetLogin(SignedMessageBlock block) {
|
||||
if (block.messageType == SignedMessageBlock.TYPE_INCOMING_TEXT
|
||||
|| block.messageType == SignedMessageBlock.TYPE_READ_INCOMING) {
|
||||
return block.toLogin;
|
||||
}
|
||||
return new String(block.encryptedBodyBytes, StandardCharsets.UTF_8);
|
||||
if (block.messageType == SignedMessageBlock.TYPE_OUTGOING_COPY
|
||||
|| block.messageType == SignedMessageBlock.TYPE_READ_OUTGOING_COPY) {
|
||||
return block.fromLogin;
|
||||
}
|
||||
return block.toLogin;
|
||||
}
|
||||
}
|
||||
|
||||
+63
-37
@@ -13,43 +13,46 @@ import shine.db.dao.SignedMessagesV2DAO;
|
||||
import shine.db.entities.ActiveSessionEntry;
|
||||
import shine.db.entities.SignedMessageV2Entry;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public final class SignedMessagesRealtime {
|
||||
private static final Logger log = LoggerFactory.getLogger(SignedMessagesRealtime.class);
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private SignedMessagesRealtime() {}
|
||||
|
||||
static DeliveryCounters deliverToTargetSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception {
|
||||
return deliverToTargetSessions(message, block, null);
|
||||
static DeliveryCounters deliverToRelevantSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception {
|
||||
return deliverToRelevantSessions(message, block, null);
|
||||
}
|
||||
|
||||
static DeliveryCounters deliverToTargetSessions(
|
||||
static DeliveryCounters deliverToRelevantSessions(
|
||||
SignedMessageV2Entry message,
|
||||
SignedMessageBlock block,
|
||||
String excludeSessionId
|
||||
) throws Exception {
|
||||
DeliveryCounters counters = new DeliveryCounters();
|
||||
List<ActiveSessionEntry> sessions = ActiveSessionsDAO.getInstance().getByLogin(message.getTargetLogin());
|
||||
long now = System.currentTimeMillis();
|
||||
for (ActiveSessionEntry s : sessions) {
|
||||
String sessionId = s.getSessionId();
|
||||
if (excludeSessionId != null && excludeSessionId.equals(sessionId)) {
|
||||
continue;
|
||||
}
|
||||
SignedMessagesV2DAO.getInstance().ensureDeliveryRow(message.getMessageKey(), sessionId, now);
|
||||
boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, message, false);
|
||||
if (deliveredOnline) {
|
||||
counters.wsDelivered++;
|
||||
continue;
|
||||
}
|
||||
if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT
|
||||
&& block != null
|
||||
&& block.revisionTimeMs == 0
|
||||
&& !block.isDeletedContent()) {
|
||||
boolean pushed = pushNewMessageNotification(s, message, block);
|
||||
if (pushed) counters.pushDelivered++;
|
||||
for (String targetLogin : targetLoginsForMessage(message)) {
|
||||
List<ActiveSessionEntry> sessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin);
|
||||
for (ActiveSessionEntry s : sessions) {
|
||||
String sessionId = s.getSessionId();
|
||||
if (excludeSessionId != null && excludeSessionId.equals(sessionId)) {
|
||||
continue;
|
||||
}
|
||||
SignedMessagesV2DAO.getInstance().ensureDeliveryRow(message.getMessageKey(), sessionId, now);
|
||||
boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, targetLogin, message, false);
|
||||
if (deliveredOnline) {
|
||||
counters.wsDelivered++;
|
||||
continue;
|
||||
}
|
||||
if (shouldPushNewIncomingMessage(targetLogin, message, block)) {
|
||||
boolean pushed = pushNewMessageNotification(s, message);
|
||||
if (pushed) counters.pushDelivered++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return counters;
|
||||
@@ -65,14 +68,19 @@ public final class SignedMessagesRealtime {
|
||||
List<SignedMessageV2Entry> pending = SignedMessagesV2DAO.getInstance()
|
||||
.listPendingForSession(login, sessionId);
|
||||
for (SignedMessageV2Entry e : pending) {
|
||||
sendEventToSessionIfOnline(sessionId, e, true);
|
||||
sendEventToSessionIfOnline(sessionId, login, e, true);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to dispatch pending messages for sessionId={}", sessionId, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean sendEventToSessionIfOnline(String sessionId, SignedMessageV2Entry message, boolean backlog) {
|
||||
private static boolean sendEventToSessionIfOnline(
|
||||
String sessionId,
|
||||
String actualTargetLogin,
|
||||
SignedMessageV2Entry message,
|
||||
boolean backlog
|
||||
) {
|
||||
ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId);
|
||||
if (targetCtx == null) return false;
|
||||
|
||||
@@ -82,7 +90,7 @@ public final class SignedMessagesRealtime {
|
||||
payload.put("baseKey", message.getBaseKey());
|
||||
payload.put("fromLogin", message.getFromLogin());
|
||||
payload.put("toLogin", message.getToLogin());
|
||||
payload.put("targetLogin", message.getTargetLogin());
|
||||
payload.put("targetLogin", actualTargetLogin);
|
||||
payload.put("messageType", message.getMessageType());
|
||||
payload.put("timeMs", message.getTimeMs());
|
||||
payload.put("nonce", message.getNonce());
|
||||
@@ -91,27 +99,23 @@ public final class SignedMessagesRealtime {
|
||||
if (message.getReceiptRefBaseKey() != null) {
|
||||
payload.put("receiptRefBaseKey", message.getReceiptRefBaseKey());
|
||||
}
|
||||
if (message.getReceiptRefType() != null) {
|
||||
payload.put("receiptRefType", message.getReceiptRefType());
|
||||
}
|
||||
return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload);
|
||||
}
|
||||
|
||||
private static boolean pushNewMessageNotification(
|
||||
ActiveSessionEntry session,
|
||||
SignedMessageV2Entry message,
|
||||
SignedMessageBlock block
|
||||
) {
|
||||
private static boolean shouldPushNewIncomingMessage(String targetLogin, SignedMessageV2Entry message, SignedMessageBlock block) {
|
||||
if (block == null) return false;
|
||||
if (message.getMessageType() != SignedMessageBlock.TYPE_INCOMING_TEXT) return false;
|
||||
if (!targetLogin.equalsIgnoreCase(message.getToLogin())) return false;
|
||||
return block.revisionTimeMs == 0;
|
||||
}
|
||||
|
||||
private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageV2Entry message) {
|
||||
try {
|
||||
if (session == null) return false;
|
||||
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
|
||||
return false;
|
||||
}
|
||||
String preview = SignedMessagesCore.previewTextForPush(block).replace('\n', ' ').trim();
|
||||
if (preview.length() > 80) preview = preview.substring(0, 80) + "...";
|
||||
String text = preview.isBlank()
|
||||
? "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения."
|
||||
: preview;
|
||||
String text = "Вам пришло новое личное сообщение от " + message.getFromLogin() + ".";
|
||||
String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}";
|
||||
return WebPushSender.sendBase64Payload(
|
||||
session.getPushEndpoint(),
|
||||
@@ -124,6 +128,28 @@ public final class SignedMessagesRealtime {
|
||||
}
|
||||
}
|
||||
|
||||
private static List<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) {
|
||||
if (s == null) return "";
|
||||
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
|
||||
server.version=1.2.277
|
||||
client.version=1.2.298
|
||||
server.version=1.2.278
|
||||
|
||||
+43
-4
@@ -27,6 +27,8 @@ import {
|
||||
state,
|
||||
terminateCurrentSession,
|
||||
addSignedMessageToChat,
|
||||
deleteConversationMessagesBefore,
|
||||
deleteSignedMessageByBaseKey,
|
||||
markIncomingReadByBaseKey,
|
||||
markOutgoingReadByBaseKey,
|
||||
normalizeDmChatId,
|
||||
@@ -1018,10 +1020,29 @@ async function init() {
|
||||
const fromLogin = parsed.fromLogin || '';
|
||||
const toLogin = parsed.toLogin || '';
|
||||
const messageType = Number(parsed.messageType || 0);
|
||||
const chatId = normalizeDmChatId(messageType === 2 ? toLogin : fromLogin);
|
||||
const text = (messageType === 1 || messageType === 2)
|
||||
? String(parsed.text || '')
|
||||
: '';
|
||||
const currentLogin = String(state.session?.login || '').trim().toLowerCase();
|
||||
const chatPeerLogin = currentLogin && currentLogin === String(fromLogin || '').trim().toLowerCase()
|
||||
? toLogin
|
||||
: fromLogin;
|
||||
const chatId = normalizeDmChatId(chatPeerLogin);
|
||||
let text = '';
|
||||
if (messageType === 1 || messageType === 2) {
|
||||
try {
|
||||
const decrypted = await authService.decryptSignedMessageContent({
|
||||
parsed,
|
||||
login: state.session?.login || '',
|
||||
storagePwd: state.session?.storagePwdInMemory || '',
|
||||
});
|
||||
text = String(decrypted?.text || '');
|
||||
} catch (error) {
|
||||
addAppLogEntry({
|
||||
level: 'warn',
|
||||
source: 'signed-dm',
|
||||
message: 'Не удалось расшифровать DM',
|
||||
details: { messageKey, baseKey: parsed.baseKey, error: error?.message || 'unknown' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let shouldRefreshToolbarUnread = false;
|
||||
|
||||
@@ -1084,6 +1105,24 @@ async function init() {
|
||||
message: 'Получено подтверждение прочтения',
|
||||
details: { messageKey, baseKey: parsed.baseKey, messageType },
|
||||
});
|
||||
} else if (messageType === 5 || messageType === 6) {
|
||||
const changed = deleteSignedMessageByBaseKey(chatId, parsed.baseKey);
|
||||
if (changed) shouldRefreshToolbarUnread = true;
|
||||
addAppLogEntry({
|
||||
level: 'info',
|
||||
source: 'signed-dm',
|
||||
message: 'Получено удаление сообщения',
|
||||
details: { messageKey, baseKey: parsed.baseKey, messageType, changed },
|
||||
});
|
||||
} else if (messageType === 7 || messageType === 8) {
|
||||
const removed = deleteConversationMessagesBefore(chatId, Number(parsed.timeMs || 0));
|
||||
if (removed > 0) shouldRefreshToolbarUnread = true;
|
||||
addAppLogEntry({
|
||||
level: 'info',
|
||||
source: 'signed-dm',
|
||||
message: 'Получено удаление истории переписки',
|
||||
details: { messageKey, baseKey: parsed.baseKey, messageType, removed, boundaryTimeMs: Number(parsed.timeMs || 0) },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -594,16 +594,25 @@ export function render({ navigate, route }) {
|
||||
}
|
||||
};
|
||||
|
||||
const applyLocalRevision = ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
|
||||
const applyLocalRevision = async ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
|
||||
if (!localOutgoingBlobB64) return;
|
||||
try {
|
||||
const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64);
|
||||
let text = '';
|
||||
if (Number(parsed?.messageType || 0) === 1 || Number(parsed?.messageType || 0) === 2) {
|
||||
const decrypted = await authService.decryptSignedMessageContent({
|
||||
parsed,
|
||||
login: state.session.login,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
});
|
||||
text = String(decrypted?.text || '');
|
||||
}
|
||||
addSignedMessageToChat({
|
||||
chatId,
|
||||
messageKey: fallbackMessageKey || parsed?.messageKey || '',
|
||||
baseKey: fallbackBaseKey || parsed?.baseKey || '',
|
||||
from: 'out',
|
||||
text: parsed?.text || '',
|
||||
text,
|
||||
messageType: Number(parsed?.messageType || 2),
|
||||
unread: false,
|
||||
rawBlobB64: localOutgoingBlobB64,
|
||||
@@ -625,11 +634,13 @@ export function render({ navigate, route }) {
|
||||
timeMs: base.timeMs,
|
||||
nonce: base.nonce,
|
||||
revisionTimeMs: Date.now(),
|
||||
deleteByRecipient: msg?.from === 'in',
|
||||
});
|
||||
applyLocalRevision({
|
||||
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
|
||||
fallbackMessageKey: result?.outgoingKey || '',
|
||||
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
|
||||
addSignedMessageToChat({
|
||||
chatId,
|
||||
messageKey: String(msg?.messageKey || ''),
|
||||
baseKey: String(msg?.baseKey || ''),
|
||||
deleted: true,
|
||||
});
|
||||
if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) {
|
||||
cancelEditMode({ restoreDraft: true });
|
||||
@@ -670,7 +681,7 @@ export function render({ navigate, route }) {
|
||||
});
|
||||
}
|
||||
|
||||
applyLocalRevision({
|
||||
void applyLocalRevision({
|
||||
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
|
||||
fallbackMessageKey: result?.outgoingKey || '',
|
||||
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
|
||||
@@ -857,7 +868,6 @@ async function sendReadReceiptsForVisible(chatId) {
|
||||
refFromLogin: ref.fromLogin,
|
||||
refTimeMs: ref.timeMs,
|
||||
refNonce: ref.nonce,
|
||||
refType: 1,
|
||||
});
|
||||
if (row.baseKey) {
|
||||
markReadReceiptSentByBaseKey(row.baseKey);
|
||||
|
||||
@@ -4,16 +4,26 @@ import {
|
||||
bytesToBase64,
|
||||
deriveEd25519FromMasterSecret,
|
||||
deriveMasterSecretFromPassword,
|
||||
ed25519PublicToX25519Public,
|
||||
ed25519SeedToX25519Private,
|
||||
exportEd25519PublicKeyB64,
|
||||
exportPkcs8B64,
|
||||
generateEd25519Pair,
|
||||
extractEd25519SeedFromPkcs8B64,
|
||||
hkdfSha256,
|
||||
importPkcs8Ed25519,
|
||||
publicKeyB64FromPkcs8Ed25519,
|
||||
randomBase64,
|
||||
randomBytes,
|
||||
sha256Bytes,
|
||||
signBytes,
|
||||
signBase64,
|
||||
utf8Bytes,
|
||||
x25519PublicFromPrivate,
|
||||
x25519RandomPrivateKey,
|
||||
x25519SharedSecret,
|
||||
encryptBytesAesGcm,
|
||||
decryptBytesAesGcm,
|
||||
} from './crypto-utils.js';
|
||||
import {
|
||||
channelNameErrorText,
|
||||
@@ -215,14 +225,20 @@ function uint8Bytes(value) {
|
||||
}
|
||||
|
||||
const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM');
|
||||
const DM2_PREFIX = utf8Bytes('SHiNE_dm2');
|
||||
const DM2_TYPE_INCOMING = 1;
|
||||
const DM2_TYPE_OUTGOING_COPY = 2;
|
||||
const DM2_TYPE_READ_INCOMING = 3;
|
||||
const DM2_TYPE_READ_OUTGOING_COPY = 4;
|
||||
const DM_TYPE_INCOMING = 1;
|
||||
const DM_TYPE_OUTGOING_COPY = 2;
|
||||
const DM_TYPE_READ_INCOMING = 3;
|
||||
const DM_TYPE_READ_OUTGOING_COPY = 4;
|
||||
const DM_TYPE_MESSAGE_DELETED_BY_SENDER = 5;
|
||||
const DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT = 6;
|
||||
const DM_TYPE_CONVERSATION_DELETED_BY_SENDER = 7;
|
||||
const DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT = 8;
|
||||
const DM_FORMAT_VERSION_MAJOR = 1;
|
||||
const DM_FORMAT_VERSION_MINOR = 0;
|
||||
const DM_MAX_ENCRYPTED_BODY_BYTES = 16384;
|
||||
const DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM = 1;
|
||||
const DM_CRYPTO_VERSION_1_0 = 0;
|
||||
const DM_HKDF_INFO = utf8Bytes('SHiNE_DM|1|0|X25519+HKDF-SHA256+AES-256-GCM');
|
||||
|
||||
function ensureAsciiBytes(value, field, min = 1, max = 60) {
|
||||
const text = String(value || '').trim();
|
||||
@@ -237,15 +253,15 @@ function ensureAsciiBytes(value, field, min = 1, max = 60) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function dm2BaseKey({ toLogin, fromLogin, timeMs, nonce }) {
|
||||
function dmBaseKey({ toLogin, fromLogin, timeMs, nonce }) {
|
||||
return `${fromLogin}|${toLogin}|${Number(timeMs)}|${Number(nonce)}`;
|
||||
}
|
||||
|
||||
function dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }) {
|
||||
return `${dm2BaseKey({ toLogin, fromLogin, timeMs, nonce })}|${Number(messageType)}`;
|
||||
function dmMessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }) {
|
||||
return `${dmBaseKey({ toLogin, fromLogin, timeMs, nonce })}|${Number(messageType)}`;
|
||||
}
|
||||
|
||||
function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType }) {
|
||||
function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce }) {
|
||||
const toBytes = ensureAsciiBytes(refToLogin, 'receipt.refToLogin');
|
||||
const fromBytes = ensureAsciiBytes(refFromLogin, 'receipt.refFromLogin');
|
||||
return concatBytes(
|
||||
@@ -253,10 +269,67 @@ function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, ref
|
||||
uint8Bytes(fromBytes.length), fromBytes,
|
||||
uint64Bytes(refTimeMs),
|
||||
uint32Bytes(refNonce),
|
||||
uint16Bytes(refType),
|
||||
);
|
||||
}
|
||||
|
||||
function buildDmEncryptedBodyBytes({ plainBytes, recipientClientKeyB64 }) {
|
||||
return buildDmEncryptedBodyBytesAsync({ plainBytes, recipientClientKeyB64 });
|
||||
}
|
||||
|
||||
async function buildDmEncryptedBodyBytesAsync({ plainBytes, recipientClientKeyB64 }) {
|
||||
const recipientEd25519Pub = base64ToBytes(String(recipientClientKeyB64 || '').trim());
|
||||
if (recipientEd25519Pub.length !== 32) {
|
||||
throw new Error('Некорректный clientKey получателя');
|
||||
}
|
||||
const recipientX25519Pub = ed25519PublicToX25519Public(recipientEd25519Pub);
|
||||
const ephemeralPriv = x25519RandomPrivateKey();
|
||||
const ephemeralPub = x25519PublicFromPrivate(ephemeralPriv);
|
||||
const sharedSecret = x25519SharedSecret(ephemeralPriv, recipientX25519Pub);
|
||||
const salt = concatBytes(ephemeralPub, recipientX25519Pub);
|
||||
const aesKeyBytes = await hkdfSha256(sharedSecret, salt, DM_HKDF_INFO, 32);
|
||||
const iv = randomBytes(12);
|
||||
const cipherText = await encryptBytesAesGcm(plainBytes, aesKeyBytes, iv);
|
||||
return concatBytes(
|
||||
uint8Bytes(DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM),
|
||||
uint8Bytes(DM_CRYPTO_VERSION_1_0),
|
||||
uint8Bytes(ephemeralPub.length),
|
||||
ephemeralPub,
|
||||
uint8Bytes(iv.length),
|
||||
iv,
|
||||
uint32Bytes(cipherText.length),
|
||||
cipherText,
|
||||
);
|
||||
}
|
||||
|
||||
function parseEncryptedDmBodyBytes(payloadBytes) {
|
||||
if (!(payloadBytes instanceof Uint8Array)) throw new Error('BAD_ENCRYPTED_BODY');
|
||||
let o = 0;
|
||||
const read = (n) => {
|
||||
if (o + n > payloadBytes.length) throw new Error('BAD_ENCRYPTED_BODY');
|
||||
const out = payloadBytes.slice(o, o + n);
|
||||
o += n;
|
||||
return out;
|
||||
};
|
||||
const readU8 = () => read(1)[0];
|
||||
const cryptoMethod = readU8();
|
||||
const cryptoVersion = readU8();
|
||||
const ephemeralPubKeyLen = readU8();
|
||||
const ephemeralPubKey = read(ephemeralPubKeyLen);
|
||||
const ivLen = readU8();
|
||||
const iv = read(ivLen);
|
||||
const cipherTextLenBytes = read(4);
|
||||
const cipherTextLen = new DataView(cipherTextLenBytes.buffer, cipherTextLenBytes.byteOffset, 4).getUint32(0, false);
|
||||
const cipherText = read(cipherTextLen);
|
||||
if (o !== payloadBytes.length) throw new Error('BAD_ENCRYPTED_BODY');
|
||||
return {
|
||||
cryptoMethod,
|
||||
cryptoVersion,
|
||||
ephemeralPubKey,
|
||||
iv,
|
||||
cipherText,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSignedMessageBlockBytes(bytes) {
|
||||
if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array');
|
||||
let o = 0;
|
||||
@@ -267,11 +340,6 @@ function parseSignedMessageBlockBytes(bytes) {
|
||||
return out;
|
||||
};
|
||||
const readU8 = () => read(1)[0];
|
||||
const readU16 = () => {
|
||||
const part = read(2);
|
||||
const view = new DataView(part.buffer, part.byteOffset, 2);
|
||||
return view.getUint16(0, false);
|
||||
};
|
||||
const readU32 = () => {
|
||||
const part = read(4);
|
||||
const view = new DataView(part.buffer, part.byteOffset, 4);
|
||||
@@ -301,87 +369,59 @@ function parseSignedMessageBlockBytes(bytes) {
|
||||
return true;
|
||||
};
|
||||
|
||||
if (startsWith(DM_PREFIX_V1)) {
|
||||
read(DM_PREFIX_V1.length);
|
||||
const formatVersionMajor = readU8();
|
||||
const formatVersionMinor = readU8();
|
||||
const toLogin = readAscii();
|
||||
const fromLogin = readAscii();
|
||||
const timeMs = readU64();
|
||||
const nonce = readU32();
|
||||
const messageType = readU16();
|
||||
const revisionTimeMs = readU64();
|
||||
const attachmentsCount = readU8();
|
||||
if (attachmentsCount !== 0) throw new Error('ATTACHMENTS_DISABLED');
|
||||
const encryptedBodyLen = readU32();
|
||||
const encryptedBodyBytes = read(encryptedBodyLen);
|
||||
const signatureBytes = read(64);
|
||||
if (o !== bytes.length) throw new Error('BAD_LEN');
|
||||
const signedBody = bytes.slice(0, bytes.length - 64);
|
||||
const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce });
|
||||
const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
|
||||
const bodyText = new TextDecoder().decode(encryptedBodyBytes);
|
||||
return {
|
||||
toLogin,
|
||||
fromLogin,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType,
|
||||
revisionTimeMs,
|
||||
formatVersionMajor,
|
||||
formatVersionMinor,
|
||||
encryptedBodyBytes,
|
||||
encryptedBodyText: bodyText,
|
||||
text: bodyText,
|
||||
bodyAttachments: [],
|
||||
payloadBytes: encryptedBodyBytes,
|
||||
signatureBytes,
|
||||
signedBody,
|
||||
rawBytes: bytes,
|
||||
baseKey,
|
||||
messageKey,
|
||||
legacyFormat: false,
|
||||
deleted: attachmentsCount === 0 && encryptedBodyLen === 0,
|
||||
};
|
||||
}
|
||||
|
||||
const prefix = read(DM2_PREFIX.length);
|
||||
for (let i = 0; i < DM2_PREFIX.length; i += 1) {
|
||||
if (prefix[i] !== DM2_PREFIX[i]) throw new Error('BAD_PREFIX');
|
||||
}
|
||||
if (!startsWith(DM_PREFIX_V1)) throw new Error('BAD_PREFIX');
|
||||
|
||||
read(DM_PREFIX_V1.length);
|
||||
const formatVersionMajor = readU8();
|
||||
const formatVersionMinor = readU8();
|
||||
const toLogin = readAscii();
|
||||
const fromLogin = readAscii();
|
||||
const timeMs = readU64();
|
||||
const nonce = readU32();
|
||||
const messageType = readU16();
|
||||
const payloadLen = readU16();
|
||||
const payloadBytes = read(payloadLen);
|
||||
const messageType = readU8();
|
||||
const revisionTimeMs = readU64();
|
||||
const reencryptedAtMs = readU64();
|
||||
const bodyLen = readU32();
|
||||
const bodyBytes = read(bodyLen);
|
||||
const signatureBytes = read(64);
|
||||
if (o !== bytes.length) throw new Error('BAD_LEN');
|
||||
|
||||
const signedBody = bytes.slice(0, bytes.length - 64);
|
||||
const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce });
|
||||
const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
|
||||
const baseKey = dmBaseKey({ toLogin, fromLogin, timeMs, nonce });
|
||||
const messageKey = dmMessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
|
||||
let encryptedBodyPayload = null;
|
||||
let readReceiptPayload = null;
|
||||
if (messageType === DM_TYPE_INCOMING || messageType === DM_TYPE_OUTGOING_COPY) {
|
||||
encryptedBodyPayload = parseEncryptedDmBodyBytes(bodyBytes);
|
||||
} else if (messageType === DM_TYPE_READ_INCOMING || messageType === DM_TYPE_READ_OUTGOING_COPY) {
|
||||
readReceiptPayload = null;
|
||||
}
|
||||
|
||||
return {
|
||||
toLogin,
|
||||
fromLogin,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType,
|
||||
revisionTimeMs: 0,
|
||||
encryptedBodyBytes: payloadBytes,
|
||||
encryptedBodyText: new TextDecoder().decode(payloadBytes),
|
||||
text: new TextDecoder().decode(payloadBytes),
|
||||
revisionTimeMs,
|
||||
reencryptedAtMs,
|
||||
formatVersionMajor,
|
||||
formatVersionMinor,
|
||||
encryptedBodyBytes: bodyBytes,
|
||||
encryptedBodyPayload,
|
||||
text: '',
|
||||
bodyAttachments: [],
|
||||
payloadBytes,
|
||||
payloadBytes: bodyBytes,
|
||||
signatureBytes,
|
||||
signedBody,
|
||||
rawBytes: bytes,
|
||||
baseKey,
|
||||
messageKey,
|
||||
legacyFormat: true,
|
||||
deleted: false,
|
||||
legacyFormat: false,
|
||||
deleted: messageType >= DM_TYPE_MESSAGE_DELETED_BY_SENDER,
|
||||
isMessageDelete: messageType === DM_TYPE_MESSAGE_DELETED_BY_SENDER || messageType === DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT,
|
||||
isConversationDelete: messageType === DM_TYPE_CONVERSATION_DELETED_BY_SENDER || messageType === DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT,
|
||||
readReceiptPayload,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2186,64 +2226,28 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async buildSignedDm2Block({
|
||||
login,
|
||||
toLogin,
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType,
|
||||
payloadBytes,
|
||||
}) {
|
||||
const cleanFromLogin = String(login || '').trim();
|
||||
const cleanToLogin = String(toLogin || '').trim();
|
||||
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
|
||||
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
|
||||
|
||||
const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
|
||||
const clientPriv = secrets?.clientKey || secrets?.clientKey;
|
||||
if (!clientPriv) throw new Error('Не найден приватный clientKey');
|
||||
const privateKey = await importPkcs8Ed25519(clientPriv);
|
||||
|
||||
const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin');
|
||||
const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin');
|
||||
if (!(payloadBytes instanceof Uint8Array) || payloadBytes.length < 1 || payloadBytes.length > 4096) {
|
||||
throw new Error('payload должен быть 1..4096 байт');
|
||||
}
|
||||
|
||||
const preimage = concatBytes(
|
||||
DM2_PREFIX,
|
||||
uint8Bytes(toBytes.length), toBytes,
|
||||
uint8Bytes(fromBytes.length), fromBytes,
|
||||
uint64Bytes(timeMs),
|
||||
uint32Bytes(nonce),
|
||||
uint16Bytes(messageType),
|
||||
uint16Bytes(payloadBytes.length),
|
||||
payloadBytes,
|
||||
);
|
||||
const signature = await signBytes(privateKey, preimage);
|
||||
return concatBytes(preimage, signature);
|
||||
}
|
||||
|
||||
async buildSignedDmV1Block({
|
||||
login,
|
||||
async buildSignedDmBlock({
|
||||
signerLogin,
|
||||
fromLogin,
|
||||
toLogin,
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType,
|
||||
revisionTimeMs = 0,
|
||||
encryptedBodyBytes = new Uint8Array(0),
|
||||
reencryptedAtMs = 0,
|
||||
bodyBytes = new Uint8Array(0),
|
||||
}) {
|
||||
const cleanFromLogin = String(login || '').trim();
|
||||
const cleanSignerLogin = String(signerLogin || '').trim();
|
||||
const cleanFromLogin = String(fromLogin || '').trim();
|
||||
const cleanToLogin = String(toLogin || '').trim();
|
||||
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
|
||||
if (!cleanSignerLogin || !cleanFromLogin || !cleanToLogin) throw new Error('Не передан signerLogin/fromLogin/toLogin');
|
||||
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
|
||||
if (!(encryptedBodyBytes instanceof Uint8Array) || encryptedBodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) {
|
||||
throw new Error(`encryptedBody должен быть 0..${DM_MAX_ENCRYPTED_BODY_BYTES} байт`);
|
||||
if (!(bodyBytes instanceof Uint8Array) || bodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) {
|
||||
throw new Error(`body должен быть 0..${DM_MAX_ENCRYPTED_BODY_BYTES} байт`);
|
||||
}
|
||||
|
||||
const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
|
||||
const secrets = await loadEncryptedUserSecrets(cleanSignerLogin, storagePwd);
|
||||
const clientPriv = secrets?.clientKey || secrets?.clientKey;
|
||||
if (!clientPriv) throw new Error('Не найден приватный clientKey');
|
||||
const privateKey = await importPkcs8Ed25519(clientPriv);
|
||||
@@ -2258,11 +2262,11 @@ export class AuthService {
|
||||
uint8Bytes(fromBytes.length), fromBytes,
|
||||
uint64Bytes(timeMs),
|
||||
uint32Bytes(nonce),
|
||||
uint16Bytes(messageType),
|
||||
uint8Bytes(messageType),
|
||||
uint64Bytes(revisionTimeMs),
|
||||
uint8Bytes(0),
|
||||
uint32Bytes(encryptedBodyBytes.length),
|
||||
encryptedBodyBytes,
|
||||
uint64Bytes(reencryptedAtMs),
|
||||
uint32Bytes(bodyBytes.length),
|
||||
bodyBytes,
|
||||
);
|
||||
const signature = await signBytes(privateKey, preimage);
|
||||
return concatBytes(preimage, signature);
|
||||
@@ -2283,10 +2287,6 @@ export class AuthService {
|
||||
return out;
|
||||
};
|
||||
const readU8 = () => read(1)[0];
|
||||
const readU16 = () => {
|
||||
const part = read(2);
|
||||
return new DataView(part.buffer, part.byteOffset, 2).getUint16(0, false);
|
||||
};
|
||||
const readU32 = () => {
|
||||
const part = read(4);
|
||||
return new DataView(part.buffer, part.byteOffset, 4).getUint32(0, false);
|
||||
@@ -2304,9 +2304,40 @@ export class AuthService {
|
||||
const refFromLogin = readAscii();
|
||||
const refTimeMs = readU64();
|
||||
const refNonce = readU32();
|
||||
const refType = readU16();
|
||||
if (o !== payloadBytes.length) throw new Error('BAD_RECEIPT_LEN');
|
||||
return { refToLogin, refFromLogin, refTimeMs, refNonce, refType };
|
||||
return { refToLogin, refFromLogin, refTimeMs, refNonce };
|
||||
}
|
||||
|
||||
async decryptSignedMessageContent({ parsed, blobB64 = '', login, storagePwd }) {
|
||||
const parsedBlock = parsed || this.parseSignedMessageBlob(blobB64);
|
||||
const messageType = Number(parsedBlock?.messageType || 0);
|
||||
if (messageType !== DM_TYPE_INCOMING && messageType !== DM_TYPE_OUTGOING_COPY) {
|
||||
return { text: '', plainBytes: new Uint8Array(0) };
|
||||
}
|
||||
if (!storagePwd) throw new Error('Не передан storagePwd для расшифровки');
|
||||
const cleanLogin = String(login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Не передан login для расшифровки');
|
||||
|
||||
const payload = parsedBlock.encryptedBodyPayload || parseEncryptedDmBodyBytes(parsedBlock.payloadBytes);
|
||||
if (payload.cryptoMethod !== DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM || payload.cryptoVersion !== DM_CRYPTO_VERSION_1_0) {
|
||||
throw new Error('Неподдерживаемый метод шифрования DM');
|
||||
}
|
||||
|
||||
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
const clientPrivPkcs8 = String(secrets?.clientKey || '').trim();
|
||||
if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey');
|
||||
const recipientSeed = extractEd25519SeedFromPkcs8B64(clientPrivPkcs8);
|
||||
const recipientX25519Priv = ed25519SeedToX25519Private(recipientSeed);
|
||||
const recipientEd25519PubB64 = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8);
|
||||
const recipientX25519Pub = ed25519PublicToX25519Public(base64ToBytes(recipientEd25519PubB64));
|
||||
const sharedSecret = x25519SharedSecret(recipientX25519Priv, payload.ephemeralPubKey);
|
||||
const salt = concatBytes(payload.ephemeralPubKey, recipientX25519Pub);
|
||||
const aesKeyBytes = await hkdfSha256(sharedSecret, salt, DM_HKDF_INFO, 32);
|
||||
const plainBytes = await decryptBytesAesGcm(payload.cipherText, aesKeyBytes, payload.iv);
|
||||
return {
|
||||
text: new TextDecoder().decode(plainBytes),
|
||||
plainBytes,
|
||||
};
|
||||
}
|
||||
|
||||
async sendMessagePair({ incomingBlobB64, outgoingBlobB64 }) {
|
||||
@@ -2330,6 +2361,7 @@ export class AuthService {
|
||||
timeMs,
|
||||
nonce,
|
||||
revisionTimeMs = 0,
|
||||
reencryptedAtMs = 0,
|
||||
}) {
|
||||
const cleanFromLogin = String(login || '').trim();
|
||||
const cleanToLogin = String(toLogin || '').trim();
|
||||
@@ -2338,30 +2370,54 @@ export class AuthService {
|
||||
const normalizedTimeMs = Number(timeMs);
|
||||
const normalizedNonce = Number(nonce);
|
||||
const normalizedRevisionTimeMs = Number(revisionTimeMs || 0);
|
||||
const normalizedReencryptedAtMs = Number(reencryptedAtMs || 0);
|
||||
if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs');
|
||||
if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce');
|
||||
if (!Number.isFinite(normalizedRevisionTimeMs) || normalizedRevisionTimeMs < 0) throw new Error('Некорректный revisionTimeMs');
|
||||
const encryptedBodyBytes = utf8Bytes(cleanText);
|
||||
if (!Number.isFinite(normalizedReencryptedAtMs) || normalizedReencryptedAtMs < 0) throw new Error('Некорректный reencryptedAtMs');
|
||||
|
||||
const incomingBlock = await this.buildSignedDmV1Block({
|
||||
login: cleanFromLogin,
|
||||
toLogin: cleanToLogin,
|
||||
storagePwd,
|
||||
timeMs: normalizedTimeMs,
|
||||
nonce: normalizedNonce,
|
||||
messageType: DM2_TYPE_INCOMING,
|
||||
revisionTimeMs: normalizedRevisionTimeMs,
|
||||
encryptedBodyBytes,
|
||||
const plainBytes = utf8Bytes(cleanText);
|
||||
const targetUser = await this.getUser(cleanToLogin);
|
||||
if (!targetUser?.exists || !String(targetUser?.clientKey || '').trim()) {
|
||||
throw new Error('Не найден clientKey получателя');
|
||||
}
|
||||
const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
|
||||
const senderPrivPkcs8 = String(secrets?.clientKey || '').trim();
|
||||
if (!senderPrivPkcs8) throw new Error('Не найден приватный clientKey отправителя');
|
||||
const senderPublicKeyB64 = await publicKeyB64FromPkcs8Ed25519(senderPrivPkcs8);
|
||||
|
||||
const incomingEncryptedBodyBytes = await buildDmEncryptedBodyBytes({
|
||||
plainBytes,
|
||||
recipientClientKeyB64: String(targetUser.clientKey || '').trim(),
|
||||
});
|
||||
const outgoingBlock = await this.buildSignedDmV1Block({
|
||||
login: cleanFromLogin,
|
||||
const outgoingEncryptedBodyBytes = await buildDmEncryptedBodyBytes({
|
||||
plainBytes,
|
||||
recipientClientKeyB64: senderPublicKeyB64,
|
||||
});
|
||||
|
||||
const incomingBlock = await this.buildSignedDmBlock({
|
||||
signerLogin: cleanFromLogin,
|
||||
fromLogin: cleanFromLogin,
|
||||
toLogin: cleanToLogin,
|
||||
storagePwd,
|
||||
timeMs: normalizedTimeMs,
|
||||
nonce: normalizedNonce,
|
||||
messageType: DM2_TYPE_OUTGOING_COPY,
|
||||
messageType: DM_TYPE_INCOMING,
|
||||
revisionTimeMs: normalizedRevisionTimeMs,
|
||||
encryptedBodyBytes,
|
||||
reencryptedAtMs: normalizedReencryptedAtMs,
|
||||
bodyBytes: incomingEncryptedBodyBytes,
|
||||
});
|
||||
const outgoingBlock = await this.buildSignedDmBlock({
|
||||
signerLogin: cleanFromLogin,
|
||||
fromLogin: cleanFromLogin,
|
||||
toLogin: cleanToLogin,
|
||||
storagePwd,
|
||||
timeMs: normalizedTimeMs,
|
||||
nonce: normalizedNonce,
|
||||
messageType: DM_TYPE_OUTGOING_COPY,
|
||||
revisionTimeMs: normalizedRevisionTimeMs,
|
||||
reencryptedAtMs: normalizedReencryptedAtMs,
|
||||
bodyBytes: outgoingEncryptedBodyBytes,
|
||||
});
|
||||
|
||||
const payload = await this.sendMessagePair({
|
||||
@@ -2372,7 +2428,7 @@ export class AuthService {
|
||||
...payload,
|
||||
localIncomingBlobB64: bytesToBase64(incomingBlock),
|
||||
localOutgoingBlobB64: bytesToBase64(outgoingBlock),
|
||||
localBaseKey: dm2BaseKey({ toLogin: cleanToLogin, fromLogin: cleanFromLogin, timeMs: normalizedTimeMs, nonce: normalizedNonce }),
|
||||
localBaseKey: dmBaseKey({ toLogin: cleanToLogin, fromLogin: cleanFromLogin, timeMs: normalizedTimeMs, nonce: normalizedNonce }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2390,40 +2446,62 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs }) {
|
||||
return this.sendDirectMessageRevision({
|
||||
login,
|
||||
toLogin,
|
||||
text: '',
|
||||
async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs, deleteByRecipient = false }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanPeerLogin = String(toLogin || '').trim();
|
||||
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
|
||||
const normalizedTimeMs = Number(timeMs);
|
||||
const normalizedNonce = Number(nonce);
|
||||
const normalizedRevisionTimeMs = Number(revisionTimeMs || 0);
|
||||
if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs');
|
||||
if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce');
|
||||
if (!Number.isFinite(normalizedRevisionTimeMs) || normalizedRevisionTimeMs <= 0) throw new Error('Некорректный revisionTimeMs');
|
||||
|
||||
const block = await this.buildSignedDmBlock({
|
||||
signerLogin: cleanLogin,
|
||||
fromLogin: deleteByRecipient ? cleanPeerLogin : cleanLogin,
|
||||
toLogin: deleteByRecipient ? cleanLogin : cleanPeerLogin,
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
revisionTimeMs,
|
||||
timeMs: normalizedTimeMs,
|
||||
nonce: normalizedNonce,
|
||||
messageType: deleteByRecipient ? DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT : DM_TYPE_MESSAGE_DELETED_BY_SENDER,
|
||||
revisionTimeMs: normalizedRevisionTimeMs,
|
||||
reencryptedAtMs: 0,
|
||||
bodyBytes: new Uint8Array(0),
|
||||
});
|
||||
const blobB64 = bytesToBase64(block);
|
||||
const response = await this.ws.request('DeleteMessage', { blobB64 });
|
||||
if (response.status !== 200) throw opError('DeleteMessage', response);
|
||||
return {
|
||||
...(response.payload || {}),
|
||||
localBlobB64: blobB64,
|
||||
};
|
||||
}
|
||||
|
||||
async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce, refType = DM2_TYPE_INCOMING }) {
|
||||
async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce }) {
|
||||
const timeMs = Date.now();
|
||||
const nonce = Math.floor(Math.random() * 0x100000000);
|
||||
const payload = buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType });
|
||||
const payload = buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce });
|
||||
|
||||
const type3 = await this.buildSignedDm2Block({
|
||||
login,
|
||||
const type3 = await this.buildSignedDmBlock({
|
||||
signerLogin: login,
|
||||
fromLogin: login,
|
||||
toLogin,
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType: DM2_TYPE_READ_INCOMING,
|
||||
payloadBytes: payload,
|
||||
messageType: DM_TYPE_READ_INCOMING,
|
||||
bodyBytes: payload,
|
||||
});
|
||||
const type4 = await this.buildSignedDm2Block({
|
||||
login,
|
||||
const type4 = await this.buildSignedDmBlock({
|
||||
signerLogin: login,
|
||||
fromLogin: login,
|
||||
toLogin,
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType: DM2_TYPE_READ_OUTGOING_COPY,
|
||||
payloadBytes: payload,
|
||||
messageType: DM_TYPE_READ_OUTGOING_COPY,
|
||||
bodyBytes: payload,
|
||||
});
|
||||
return this.sendMessagePair({
|
||||
incomingBlobB64: bytesToBase64(type3),
|
||||
@@ -2431,6 +2509,36 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteConversation({ login, toLogin, storagePwd, deleteByRecipient = false, timeMs = Date.now(), nonce = Math.floor(Math.random() * 0x100000000) }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanPeerLogin = String(toLogin || '').trim();
|
||||
if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin');
|
||||
const normalizedTimeMs = Number(timeMs);
|
||||
const normalizedNonce = Number(nonce);
|
||||
if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs');
|
||||
if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce');
|
||||
|
||||
const block = await this.buildSignedDmBlock({
|
||||
signerLogin: cleanLogin,
|
||||
fromLogin: deleteByRecipient ? cleanPeerLogin : cleanLogin,
|
||||
toLogin: deleteByRecipient ? cleanLogin : cleanPeerLogin,
|
||||
storagePwd,
|
||||
timeMs: normalizedTimeMs,
|
||||
nonce: normalizedNonce,
|
||||
messageType: deleteByRecipient ? DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT : DM_TYPE_CONVERSATION_DELETED_BY_SENDER,
|
||||
revisionTimeMs: 0,
|
||||
reencryptedAtMs: 0,
|
||||
bodyBytes: new Uint8Array(0),
|
||||
});
|
||||
const blobB64 = bytesToBase64(block);
|
||||
const response = await this.ws.request('DeleteConversation', { blobB64 });
|
||||
if (response.status !== 200) throw opError('DeleteConversation', response);
|
||||
return {
|
||||
...(response.payload || {}),
|
||||
localBlobB64: blobB64,
|
||||
};
|
||||
}
|
||||
|
||||
async ackSessionDelivery(messageKey) {
|
||||
const response = await this.ws.request('AckSessionDelivery', { messageKey });
|
||||
if (response.status !== 200) throw opError('AckSessionDelivery', response);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const encoder = new TextEncoder();
|
||||
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.';
|
||||
import { argon2idAsync } from 'https://esm.sh/@noble/hashes@1.8.0/argon2.js';
|
||||
import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519 } from 'https://esm.sh/@noble/curves@1.8.1/ed25519';
|
||||
const SHINE_KEY_DERIVATION_PREFIX = 'SHiNE-key';
|
||||
|
||||
function getCryptoApi() {
|
||||
@@ -340,3 +341,56 @@ export async function signBytes(privateKey, bytes) {
|
||||
const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, bytes);
|
||||
return new Uint8Array(signature);
|
||||
}
|
||||
|
||||
export function extractEd25519SeedFromPkcs8B64(pkcs8B64) {
|
||||
const bytes = base64ToBytes(String(pkcs8B64 || '').trim());
|
||||
if (bytes.length < 32) throw new Error('Некорректный PKCS8 ключ client.key');
|
||||
return bytes.slice(bytes.length - 32);
|
||||
}
|
||||
|
||||
export function ed25519SeedToX25519Private(seed32) {
|
||||
const seed = seed32 instanceof Uint8Array ? seed32 : new Uint8Array(seed32 || []);
|
||||
if (seed.length !== 32) throw new Error('Seed Ed25519 должен быть 32 байта');
|
||||
return new Uint8Array(edwardsToMontgomeryPriv(seed));
|
||||
}
|
||||
|
||||
export function ed25519PublicToX25519Public(ed25519Pub32) {
|
||||
const pub = ed25519Pub32 instanceof Uint8Array ? ed25519Pub32 : new Uint8Array(ed25519Pub32 || []);
|
||||
if (pub.length !== 32) throw new Error('Публичный Ed25519 ключ должен быть 32 байта');
|
||||
return new Uint8Array(edwardsToMontgomeryPub(pub));
|
||||
}
|
||||
|
||||
export function x25519RandomPrivateKey() {
|
||||
return new Uint8Array(x25519.utils.randomPrivateKey());
|
||||
}
|
||||
|
||||
export function x25519PublicFromPrivate(privateKey32) {
|
||||
const key = privateKey32 instanceof Uint8Array ? privateKey32 : new Uint8Array(privateKey32 || []);
|
||||
if (key.length !== 32) throw new Error('Приватный X25519 ключ должен быть 32 байта');
|
||||
return new Uint8Array(x25519.getPublicKey(key));
|
||||
}
|
||||
|
||||
export function x25519SharedSecret(privateKey32, publicKey32) {
|
||||
const priv = privateKey32 instanceof Uint8Array ? privateKey32 : new Uint8Array(privateKey32 || []);
|
||||
const pub = publicKey32 instanceof Uint8Array ? publicKey32 : new Uint8Array(publicKey32 || []);
|
||||
if (priv.length !== 32 || pub.length !== 32) {
|
||||
throw new Error('X25519 ключи должны быть по 32 байта');
|
||||
}
|
||||
return new Uint8Array(x25519.getSharedSecret(priv, pub));
|
||||
}
|
||||
|
||||
export async function hkdfSha256(ikmBytes, saltBytes, infoBytes, outLen) {
|
||||
const subtle = getSubtleApi();
|
||||
const baseKey = await subtle.importKey('raw', ikmBytes, 'HKDF', false, ['deriveBits']);
|
||||
const bits = await subtle.deriveBits(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: saltBytes instanceof Uint8Array ? saltBytes : new Uint8Array(saltBytes || []),
|
||||
info: infoBytes instanceof Uint8Array ? infoBytes : new Uint8Array(infoBytes || []),
|
||||
},
|
||||
baseKey,
|
||||
Number(outLen) * 8,
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
@@ -660,6 +660,45 @@ export function addSignedMessageToChat({
|
||||
return true;
|
||||
}
|
||||
|
||||
export function deleteSignedMessageByBaseKey(chatId, baseKey) {
|
||||
const normalizedChatId = normalizeDmChatId(chatId);
|
||||
const normalizedBaseKey = String(baseKey || '').trim();
|
||||
if (!normalizedChatId || !normalizedBaseKey) return false;
|
||||
const list = getChatMessages(normalizedChatId);
|
||||
let changed = false;
|
||||
for (let i = list.length - 1; i >= 0; i -= 1) {
|
||||
const row = list[i];
|
||||
if (String(row?.baseKey || '').trim() !== normalizedBaseKey) continue;
|
||||
changed = true;
|
||||
removeStoredMessageRecord(String(row?.messageKey || '').trim());
|
||||
list.splice(i, 1);
|
||||
}
|
||||
if (changed) {
|
||||
sortChatMessagesInPlace(normalizedChatId);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function deleteConversationMessagesBefore(chatId, boundaryTimeMs) {
|
||||
const normalizedChatId = normalizeDmChatId(chatId);
|
||||
const boundary = Number(boundaryTimeMs || 0);
|
||||
if (!normalizedChatId || !Number.isFinite(boundary) || boundary <= 0) return 0;
|
||||
const list = getChatMessages(normalizedChatId);
|
||||
let removed = 0;
|
||||
for (let i = list.length - 1; i >= 0; i -= 1) {
|
||||
const row = list[i];
|
||||
const rowTime = resolveChatMessageTimeMs(row);
|
||||
if (!Number.isFinite(rowTime) || rowTime >= boundary) continue;
|
||||
removed += 1;
|
||||
removeStoredMessageRecord(String(row?.messageKey || '').trim());
|
||||
list.splice(i, 1);
|
||||
}
|
||||
if (removed > 0) {
|
||||
sortChatMessagesInPlace(normalizedChatId);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export function markChatRead(chatId) {
|
||||
const normalizedChatId = normalizeDmChatId(chatId);
|
||||
const list = getChatMessages(normalizedChatId);
|
||||
|
||||
Reference in New Issue
Block a user