| author | Eric Roberts
<eric@devl.me> 2026-08-26 22:13:45 UTC |
| committer | Eric Roberts
<eric@devl.me> 2026-08-31 20:39:52 UTC |
| parent | a4c04909d5c44617fa66454248b6868e93ae4fc6 |
| borogove/ChatMessage.hx | +7 | -2 |
| borogove/persistence/Sqlite.hx | +57 | -6 |
| optional-sqlite.awk | +1 | -1 |
| test/TestSqlite.hx | +159 | -4 |
| test/persistence-tests.ts | +224 | -0 |
diff --git a/borogove/ChatMessage.hx b/borogove/ChatMessage.hx index f0c7d24..548447f 100644 --- a/borogove/ChatMessage.hx +++ b/borogove/ChatMessage.hx @@ -359,8 +359,13 @@ class ChatMessage { } @:allow(borogove) - private static function fromStanza(stanza:Stanza, localJid:JID, ?addContext: (ChatMessageBuilder, Stanza)->ChatMessageBuilder):Null<ChatMessage> { - switch Message.fromStanza(stanza, localJid, addContext, null, true).parsed { + private static function fromStanza( + stanza:Stanza, + localJid:JID, + ?addContext: (ChatMessageBuilder, Stanza)->ChatMessageBuilder, + ?encryptionInfo:EncryptionInfo + ):Null<ChatMessage> { + switch Message.fromStanza(stanza, localJid, addContext, encryptionInfo, true).parsed { case ChatMessageStanza(message): return message; default: diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx index 016e81d..f592c4d 100644 --- a/borogove/persistence/Sqlite.hx +++ b/borogove/persistence/Sqlite.hx @@ -12,6 +12,7 @@ import thenshim.Promise; import borogove.Caps; import borogove.Chat; import borogove.Chat.AvailableChat; +import borogove.EncryptionInfo; import borogove.Message; import borogove.Member; import borogove.MemberUpdate; @@ -355,6 +356,12 @@ class Sqlite implements Persistence implements KeyValueStore { "PRAGMA user_version = 20"]); } return Promise.resolve(null); + }).then(_ -> { + if (version < 21) { + return exec(["ALTER TABLE messages ADD COLUMN encryption BLOB", + "PRAGMA user_version = 21"]); + } + return Promise.resolve(null); }); }); }); @@ -1434,7 +1441,43 @@ class Sqlite implements Persistence implements KeyValueStore { }); } - private function hydrateMessages(accountId: String, rows: Iterator<{ stanza: String, timestamp: String, direction: MessageDirection, type: MessageType, status: MessageStatus, status_text: Null<String>, mam_id: String, mam_by: String, sort_id: String, sync_point: Int, sender_id: String, ?stanza_id: String, ?versions: String }>): Array<ChatMessage> { + private function hydrateEncryption(encryption:Null<{ + status: EncryptionStatus, + method: String, + ?reason: String, + ?reasonText: String, + ?methodName: String, + }>):Null<EncryptionInfo> { + if (encryption == null) return null; + + return new EncryptionInfo( + encryption.status, + encryption.method, + encryption.reason, + encryption.reasonText, + encryption.methodName + ); + } + + private function hydrateMessages( + accountId: String, + rows: Iterator<{ + stanza: String, + timestamp: String, + direction: MessageDirection, + type: MessageType, + status: MessageStatus, + status_text: Null<String>, + mam_id: String, + mam_by: String, + sort_id: String, + sync_point: Int, + sender_id: String, + encryption: Null<String>, + ?stanza_id: String, + ?versions: String + }> + ): Array<ChatMessage> { // TODO: Calls can "edit" from multiple senders, but the original direction and sender holds final accountJid = JID.parse(accountId); return { iterator: () -> rows }.map(row -> ChatMessage.fromStanza(Stanza.parse(row.stanza), accountJid, (builder, _) -> { @@ -1458,6 +1501,7 @@ class Sqlite implements Persistence implements KeyValueStore { final versions: DynamicAccess<{ timestamp: String, stanza: String, + encryption: Dynamic, }> = Json.parse(row.versions); if (versions.keys().length > 1) { @@ -1466,7 +1510,7 @@ class Sqlite implements Persistence implements KeyValueStore { if (toPushB.serverId == null && versionId != toPushB.localId) toPushB.serverId = versionId; toPushB.timestamp = version.timestamp; return toPushB; - }); + }, hydrateEncryption(version.encryption)); final toPush = versionM == null || versionM.versions.length < 1 ? versionM : versionM.versions[0]; if (toPush != null) { builder.versions.push(toPush); @@ -1476,7 +1520,7 @@ class Sqlite implements Persistence implements KeyValueStore { } } return builder; - })); + }, hydrateEncryption(row.encryption == null ? null : Json.parse(row.encryption)))); } private function hydrateCaps(o: { node: Null<String>, identities: Array<{category: String, type: String, name: String}>, features: Array<String>, ?data: Array<String> }, ver: Null<BytesData> = null) { @@ -1729,6 +1773,7 @@ class Sqlite implements Persistence implements KeyValueStore { col("mam_by"), col("sort_id"), col("sync_point"), + col("encryption", "json(encryption) AS encryption") ] .concat(customColumns ?? []) .fold((column, map:Map<String, Column>) -> { @@ -1740,7 +1785,6 @@ class Sqlite implements Persistence implements KeyValueStore { private final versionedMessageColumns = " correction_id AS stanza_id, - versions.stanza, json_group_object( CASE WHEN versions.mam_id IS NULL OR versions.mam_id='' @@ -1749,7 +1793,8 @@ class Sqlite implements Persistence implements KeyValueStore { END, json_object( 'timestamp', strftime('%FT%H:%M:%fZ', versions.created_at / 1000.0, 'unixepoch'), - 'stanza', versions.stanza + 'stanza', versions.stanza, + 'encryption', json(versions.encryption) ) ) AS versions, messages.direction, @@ -1762,7 +1807,9 @@ class Sqlite implements Persistence implements KeyValueStore { messages.mam_by, messages.sort_id, messages.sync_point, - MAX(versions.created_at)"; + MAX(versions.created_at), + json(versions.encryption) AS encryption, + versions.stanza"; private static function insertCol( name:String, @@ -1793,6 +1840,10 @@ class Sqlite implements Persistence implements KeyValueStore { insertCol("stanza", (m) -> originalMessage(m).asStanza().toString()), insertCol("status_text", (m) -> originalMessage(m).statusText), insertCol("sort_id", (m) -> originalMessage(m).sortId), + insertCol("encryption", (m) -> { + final message = originalMessage(m); + return message.encryption == null ? null : JsonPrinter.print(message.encryption); + }, 'jsonb(?)'), ]; final values = messages.map(_ -> '(${columns.map(c -> c.valueSql).join(",")})').join(","); diff --git a/optional-sqlite.awk b/optional-sqlite.awk index 2399d8b..521e6e8 100644 --- a/optional-sqlite.awk +++ b/optional-sqlite.awk @@ -64,7 +64,7 @@ skipping { } END { - print "export { borogove_Map }" >> "npm/browser-no-sqlite.js" + print "export { borogove_Map, borogove_EncryptionInfo }" >> "npm/browser-no-sqlite.js" print "export { FractionalIndexing_between, FractionalIndexing_BASE_95_DIGITS }" >> "npm/browser-no-sqlite.js" print "export { $bind, $getIterator, Std, EReg, Type, Reflect, Lambda, haxe_io_Bytes, haxe_Timer, haxe_Exception, haxe_crypto_Base64, haxe_iterators_ArrayIterator, js_Boot, js_lib_HaxeIterator, thenshim_Promise, thenshim_PromiseTools }" >> "npm/browser-no-sqlite.js" } diff --git a/test/TestSqlite.hx b/test/TestSqlite.hx index 3be65ab..4c2eed8 100644 --- a/test/TestSqlite.hx +++ b/test/TestSqlite.hx @@ -13,6 +13,7 @@ import borogove.persistence.MediaStore; import borogove.persistence.KeyValueStore; import borogove.ChatMessageBuilder; import borogove.ChatMessage; +import borogove.EncryptionInfo; import borogove.JID; import borogove.ID; import borogove.Message; @@ -719,24 +720,31 @@ class TestSqlite extends utest.Test { timestamp: String, ?localId: String, ?serverId: String, + ?serverIdBy: String, ?senderId: String, ?chatId: String, ?versions: Array<ChatMessage>, ?callSid: String, - ?syncPoint: Bool + ?syncPoint: Bool, + ?sortId: String, + ?encryption: EncryptionInfo, + ?body: String, + ?received: Bool }):ChatMessage { final senderId = params.senderId ?? "version@example.com"; final chatId = params.chatId ?? "chat@example.com"; final builder = new ChatMessageBuilder(); builder.localId = params.localId; builder.serverId = params.serverId; - builder.serverIdBy = params.serverId == null ? null : "server.example.com"; + builder.serverIdBy = params.serverId == null ? null : params.serverIdBy ?? "server.example.com"; builder.senderId = senderId; - builder.direction = MessageSent; - builder.sortId = params.localId ?? params.serverId ?? "message"; + builder.direction = params.received ?? false ? MessageReceived : MessageSent; + builder.sortId = params.sortId ?? params.localId ?? params.serverId ?? "message"; builder.timestamp = params.timestamp; builder.syncPoint = params.syncPoint ?? false; builder.versions = params.versions ?? []; + builder.encryption = params.encryption; + if (params.body != null) builder.setBody(Html.text(params.body)); builder.to = JID.parse(chatId); builder.from = JID.parse(senderId); builder.recipients = [builder.to]; @@ -747,6 +755,137 @@ class TestSqlite extends utest.Test { return builder.build(); } + public function testMessageEncryption(async: Async) { + final account = "encryption-alice@example.com"; + final chatId = "encryption-hatter@example.com"; + final expectedEncryption = new EncryptionInfo( + DecryptionFailure, + "urn:xmpp:omemo:2", + "invalid-key", + "The sender key was invalid", + "OMEMO 2" + ); + final message = makeMessage({ + timestamp: "2026-08-26T12:00:00Z", + serverId: "encrypted-message", + serverIdBy: account, + senderId: chatId, + chatId: account, + received: true, + sortId: "encrypted-a0", + syncPoint: true, + body: "Encrypted persistence marker", + encryption: expectedEncryption + }); + + persistence.storeMessages(account, [message]).then(stored -> { + assertEncryption(stored[0], expectedEncryption); + return persistence.getMessage(account, chatId, "encrypted-message", null); + }).then(fetched -> { + assertEncryption(fetched, expectedEncryption); + return persistence.searchMessages(account, chatId, "persistence marker"); + }).then(searched -> { + Assert.equals(1, searched.length); + assertEncryption(searched[0], expectedEncryption); + return persistence.getMessagesBefore(account, chatId, null); + }).then(paged -> { + Assert.equals(1, paged.length); + assertEncryption(paged[0], expectedEncryption); + return persistence.syncPoint(account, null); + }).then(syncPoint -> { + assertEncryption(syncPoint, expectedEncryption); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + + public function testCorrectedMessageEncryption(async: Async) { + final account = "encryption-correction-alice@example.com"; + final chatId = "encryption-correction-hatter@example.com"; + final originalExpected = { + text: "Original encrypted text", + encryption: new EncryptionInfo(DecryptionSuccess, "urn:xmpp:omemo:1", null, null, "OMEMO 1") + }; + final original = makeMessage({ + timestamp: "2026-08-26T12:00:00Z", + localId: "encrypted-original", + senderId: account, + chatId: chatId, + sortId: "encrypted-c0", + body: originalExpected.text, + encryption: originalExpected.encryption + }); + final correctionExpected = { + text: "Corrected encrypted text", + encryption: new EncryptionInfo( + DecryptionFailure, + "urn:xmpp:omemo:2", + "invalid-key", + "Correction could not be decrypted", + "OMEMO 2" + ) + }; + final correctionVersion = makeMessage({ + timestamp: "2026-08-26T12:01:00Z", + localId: "encrypted-correction", + senderId: account, + chatId: chatId, + sortId: "encrypted-c0", + body: correctionExpected.text, + encryption: correctionExpected.encryption + }); + final correctable = makeMessage({ + timestamp: "2026-08-26T12:01:00Z", + localId: original.localId, + senderId: account, + chatId: chatId, + sortId: "encrypted-c0", + versions: [correctionVersion] + }); + + persistence.storeMessages(account, [original]).then(_ -> { + return persistence.storeMessages(account, [correctable]); + }).then(stored -> { + final corrected = stored[0]; + Assert.equals(correctionExpected.text, corrected.text); + assertEncryption(corrected, correctionExpected.encryption); + + final storedCorrection = corrected.versions.find(version -> version.localId == correctionVersion.localId); + Assert.notNull(storedCorrection); + Assert.equals(correctionExpected.text, storedCorrection.text); + assertEncryption(storedCorrection, correctionExpected.encryption); + + final storedOriginal = corrected.versions.find(version -> version.localId == original.localId); + Assert.notNull(storedOriginal); + Assert.equals(originalExpected.text, storedOriginal.text); + assertEncryption(storedOriginal, originalExpected.encryption); + + return persistence.getMessagesBefore(account, chatId, null); + }).then(fetched -> { + Assert.equals(1, fetched.length); + final corrected = fetched[0]; + Assert.equals(correctionExpected.text, corrected.text); + assertEncryption(corrected, correctionExpected.encryption); + + final fetchedCorrection = corrected.versions.find(version -> version.localId == correctionVersion.localId); + Assert.notNull(fetchedCorrection); + Assert.equals(correctionExpected.text, fetchedCorrection.text); + assertEncryption(fetchedCorrection, correctionExpected.encryption); + + final fetchedOriginal = corrected.versions.find(version -> version.localId == original.localId); + Assert.notNull(fetchedOriginal); + Assert.equals(originalExpected.text, fetchedOriginal.text); + assertEncryption(fetchedOriginal, originalExpected.encryption); + + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } + public function testStoreReaction(async: Async) { final account = "alice@example.com"; final builder = new ChatMessageBuilder(); @@ -791,6 +930,7 @@ class TestSqlite extends utest.Test { public function testUpdateMessageStatus(async: Async) { final account = "alice@example.com"; + final expectedEncryption = new EncryptionInfo(DecryptionSuccess, "eu.siacs.conversations.axolotl", null, null, "OMEMO"); final builder = new ChatMessageBuilder(); builder.localId = "loc1"; builder.senderId = "alice@example.com"; @@ -800,12 +940,14 @@ class TestSqlite extends utest.Test { builder.from = JID.parse("alice@example.com"); builder.recipients = [builder.to]; builder.replyTo = [builder.from]; + builder.encryption = expectedEncryption; persistence.storeMessages(account, [builder.build()]).then(_ -> { return persistence.updateMessageStatus(account, "loc1", MessageDeliveredToServer, "Delivered"); }).then(updated -> { Assert.equals(MessageDeliveredToServer, updated.status); Assert.equals("Delivered", updated.statusText); + assertEncryption(updated, expectedEncryption); async.done(); }).catchError(e -> { Assert.fail(Std.string(e)); @@ -813,6 +955,16 @@ class TestSqlite extends utest.Test { }); } + private function assertEncryption(message: Null<ChatMessage>, expected: EncryptionInfo) { + Assert.notNull(message); + Assert.notNull(message.encryption); + Assert.equals(expected.status, message.encryption.status); + Assert.equals(expected.method, message.encryption.method); + Assert.equals(expected.methodName, message.encryption.methodName); + Assert.equals(expected.reason, message.encryption.reason); + Assert.equals(expected.reasonText, message.encryption.reasonText); + } + public function testSearchMessages(async: Async) { final account = "alice@example.com"; final builder = new ChatMessageBuilder(); @@ -1868,6 +2020,7 @@ class TestSqlite extends utest.Test { "mam_by", "sort_id", "sync_point", + "json(encryption) AS encryption" ]; expected.sort(Reflect.compare); @@ -1890,6 +2043,7 @@ class TestSqlite extends utest.Test { "mam_by", "sort_id", "sync_point", + "json(encryption) AS encryption" ]; final expected = defaultColumns.concat(["stanza_id"]); @@ -1916,6 +2070,7 @@ class TestSqlite extends utest.Test { "mam_by", "sort_id", "sync_point", + "json(encryption) AS encryption" ]; final expected = defaultColumns.copy(); expected[defaultColumns.indexOf(columnToReplace)] = replacementSql; diff --git a/test/persistence-tests.ts b/test/persistence-tests.ts index 0b9b21c..83151d2 100644 --- a/test/persistence-tests.ts +++ b/test/persistence-tests.ts @@ -318,6 +318,230 @@ export function sharedPersistenceTests(test: PersistenceTest) { expect(result.byLocalId.localId).toBe("loc1"); }); + test("persists message encryption information", async ({ + page, + borogove, + persistence, + }) => { + const result = await page.evaluate( + async ({ borogove, persistence }) => { + const account = "encryption-alice@example.com"; + const chatId = "encryption-hatter@example.com"; + const builder = new borogove.ChatMessageBuilder({ + serverId: "encrypted-message", + serverIdBy: account, + senderId: chatId, + direction: 0, + }); + builder.sortId = "encrypted-a0"; + builder.syncPoint = true; + builder.text = "Encrypted persistence marker"; + builder.to = borogove.JID.parse(account); + builder.from = borogove.JID.parse(chatId); + builder.recipients = [builder.to]; + builder.replyTo = [builder.from]; + builder.encryption = { + status: borogove.EncryptionStatus.DecryptionFailure, + method: "urn:xmpp:omemo:2", + methodName: "OMEMO 2", + reason: "invalid-key", + reasonText: "The sender key was invalid", + }; + + const [stored] = await persistence.storeMessages(account, [ + builder.build(), + ]); + const fetched = await persistence.getMessage( + account, + chatId, + "encrypted-message", + null, + ); + const [searched] = await persistence.searchMessages( + account, + chatId, + "persistence marker", + ); + const [paged] = await persistence.getMessagesBefore(account, chatId); + const syncPoint = await persistence.syncPoint(account, null); + + const fields = (message) => ({ + status: message?.encryption?.status, + method: message?.encryption?.method, + methodName: message?.encryption?.methodName, + reason: message?.encryption?.reason, + reasonText: message?.encryption?.reasonText, + }); + return { + stored: fields(stored), + fetched: fields(fetched), + searched: fields(searched), + paged: fields(paged), + syncPoint: fields(syncPoint), + }; + }, + { borogove, persistence }, + ); + + const expected = { + status: 1, + method: "urn:xmpp:omemo:2", + methodName: "OMEMO 2", + reason: "invalid-key", + reasonText: "The sender key was invalid", + }; + expect(result.stored).toEqual(expected); + expect(result.fetched).toEqual(expected); + expect(result.searched).toEqual(expected); + expect(result.paged).toEqual(expected); + expect(result.syncPoint).toEqual(expected); + }); + + test("preserves encryption information when updating message status", async ({ + page, + borogove, + persistence, + }) => { + const result = await page.evaluate( + async ({ borogove, persistence }) => { + const account = "encryption-status-alice@example.com"; + const builder = new borogove.ChatMessageBuilder({ + localId: "encrypted-outgoing", + senderId: account, + direction: 1, + }); + builder.sortId = "encrypted-b0"; + builder.to = borogove.JID.parse("encryption-hatter@example.com"); + builder.from = borogove.JID.parse(account); + builder.recipients = [builder.to]; + builder.replyTo = [builder.from]; + builder.encryption = { + status: borogove.EncryptionStatus.DecryptionSuccess, + method: "eu.siacs.conversations.axolotl", + methodName: "OMEMO", + reason: null, + reasonText: null, + }; + + await persistence.storeMessages(account, [builder.build()]); + const updated = await persistence.updateMessageStatus( + account, + "encrypted-outgoing", + 1, + "Delivered", + ); + return { + status: updated.encryption?.status, + method: updated.encryption?.method, + methodName: updated.encryption?.methodName, + reason: updated.encryption?.reason, + reasonText: updated.encryption?.reasonText, + }; + }, + { borogove, persistence }, + ); + + expect(result).toEqual({ + status: 0, + method: "eu.siacs.conversations.axolotl", + methodName: "OMEMO", + reason: null, + reasonText: null, + }); + }); + + test("preserves encryption information for corrected message versions", async ({ + page, + borogove, + persistence, + }) => { + const result = await page.evaluate( + async ({ borogove, persistence }) => { + const account = "encryption-correction-alice@example.com"; + const chatId = "encryption-correction-hatter@example.com"; + const originalBuilder = new borogove.ChatMessageBuilder({ + localId: "encrypted-original", + senderId: account, + direction: 1, + timestamp: "2026-08-26T12:00:00Z", + }); + originalBuilder.sortId = "encrypted-c0"; + originalBuilder.text = "Original encrypted text"; + originalBuilder.to = borogove.JID.parse(chatId); + originalBuilder.from = borogove.JID.parse(account); + originalBuilder.recipients = [originalBuilder.to]; + originalBuilder.replyTo = [originalBuilder.from]; + originalBuilder.encryption = { + status: borogove.EncryptionStatus.DecryptionSuccess, + method: "urn:xmpp:omemo:1", + methodName: "OMEMO 1", + reason: null, + reasonText: null, + }; + const original = originalBuilder.build(); + await persistence.storeMessages(account, [original]); + + const correctionBuilder = new borogove.ChatMessageBuilder({ + localId: "encrypted-correction", + senderId: account, + direction: 1, + timestamp: "2026-08-26T12:01:00Z", + }); + correctionBuilder.sortId = "encrypted-c0"; + correctionBuilder.text = "Corrected encrypted text"; + correctionBuilder.to = borogove.JID.parse(chatId); + correctionBuilder.from = borogove.JID.parse(account); + correctionBuilder.recipients = [correctionBuilder.to]; + correctionBuilder.replyTo = [correctionBuilder.from]; + correctionBuilder.encryption = { + status: borogove.EncryptionStatus.DecryptionFailure, + method: "urn:xmpp:omemo:2", + methodName: "OMEMO 2", + reason: "invalid-key", + reasonText: "Correction could not be decrypted", + }; + const correctionVersion = correctionBuilder.build(); + correctionBuilder.versions = [correctionVersion]; + correctionBuilder.localId = original.localId; + + const [stored] = await persistence.storeMessages(account, [ + correctionBuilder.build(), + ]); + const [fetched] = await persistence.getMessagesBefore(account, chatId); + const summarize = (message) => ({ + text: message.text, + method: message.encryption?.method, + reason: message.encryption?.reason, + versions: message.versions.map((version) => ({ + text: version.text, + method: version.encryption?.method, + reason: version.encryption?.reason, + })), + }); + return { stored: summarize(stored), fetched: summarize(fetched) }; + }, + { borogove, persistence }, + ); + + for (const message of [result.stored, result.fetched]) { + expect(message.text).toBe("Corrected encrypted text"); + expect(message.method).toBe("urn:xmpp:omemo:2"); + expect(message.reason).toBe("invalid-key"); + expect(message.versions).toEqual([ + { + text: "Corrected encrypted text", + method: "urn:xmpp:omemo:2", + reason: "invalid-key", + }, + { + text: "Original encrypted text", + method: "urn:xmpp:omemo:1", + reason: null, + }, + ]); + } + }); + test("storeReaction", async ({ page, borogove, persistence }) => { const result = await page.evaluate( async ({ borogove, persistence }) => {