Реализовать SHiNE_DM v1 с E2EE и tombstone

This commit is contained in:
AidarKC
2026-07-07 18:07:10 +04:00
parent 9c588bd9b5
commit 1b7a2a8f7c
33 changed files with 1636 additions and 445 deletions
+2
View File
@@ -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
+17
View File
@@ -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; }
@@ -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),
@@ -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
) {}
}
@@ -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
);
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
@@ -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();
@@ -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() {
@@ -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");
}
}
@@ -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;
}
}
@@ -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();
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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;
}
}
@@ -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
View File
@@ -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
View File
@@ -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 {
+18 -8
View File
@@ -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);
+273 -165
View File
@@ -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);
+54
View File
@@ -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);
}
+39
View File
@@ -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);