git » sdk » commit 481d792

Store and retrieve encryption info on messages

author Eric Roberts
2026-08-26 22:13:45 UTC
committer Eric Roberts
2026-08-31 20:39:52 UTC
parent a4c04909d5c44617fa66454248b6868e93ae4fc6

Store and retrieve encryption info on messages

With this change now OMEMO messages can be sent and received when the
persistence layer is SQLite (so effectively this makes it work in Safari).

It had to be added to storeMessages and the query/hydration path. Previous
refactorings made this a small change.

Many tests were added, I'm not sure I fully understand this correction/message
path, but in my testing things do work as expected.

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 }) => {