Реализовать 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
@@ -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; }