Довести DM v1 до соответствия коду и документации

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