git » sdk » commit 8569e43

Do not overwrite message with decryption failure

author Stephen Paul Weber
2026-07-06 20:50:01 UTC
committer Stephen Paul Weber
2026-07-06 20:50:01 UTC
parent 5f78a92315379fb8430606c5cd6b16901efeddc6

Do not overwrite message with decryption failure

With OMEMO we cannot decrypt twice, and we definitely can never decrypt
our own sent messages. So if one comes back to us (MUC reflection,
partial MAM overlap) we need to take the failed decryption, and merge
any useful updates (like serverId) with what we already have locally.
Not overwrite our perfectly good copy with a failure.

borogove/ChatMessageBuilder.hx +35 -0
borogove/Client.hx +17 -1
test/TestClient.hx +49 -1

diff --git a/borogove/ChatMessageBuilder.hx b/borogove/ChatMessageBuilder.hx
index c7c1771..b912d44 100644
--- a/borogove/ChatMessageBuilder.hx
+++ b/borogove/ChatMessageBuilder.hx
@@ -214,6 +214,41 @@ class ChatMessageBuilder {
 	}
 	#end
 
+	/**
+		Create a new ChatMessageBuilder from an existing ChatMessage
+	**/
+	public static function fromMessage(m: ChatMessage): ChatMessageBuilder {
+		final builder = new ChatMessageBuilder();
+		builder.localId = m.localId;
+		builder.serverId = m.serverId;
+		builder.serverIdBy = m.serverIdBy;
+		builder.sortId = m.sortId;
+		builder.type = m.type;
+		builder.syncPoint = m.syncPoint;
+		builder.replyId = m.replyId;
+		builder.timestamp = m.timestamp;
+		builder.to = m.to;
+		builder.from = m.from;
+		builder.senderId = m.senderId;
+		builder.recipients = m.recipients.array();
+		builder.replyTo = m.replyTo.array();
+		builder.replyToMessage = m.replyToMessage;
+		builder.threadId = m.threadId;
+		builder.attachments = m.attachments.array();
+		builder.reactions = m.reactions;
+		builder.direction = m.direction;
+		builder.status = m.status;
+		builder.statusText = m.statusText;
+		builder.versions = m.versions.array();
+		builder.payloads = m.payloads.array();
+		builder.encryption = m.encryption;
+		builder.linkMetadata = m.linkMetadata.array();
+		builder.stanza = m.stanza;
+		@:privateAccess builder.text = m.text;
+		builder.lang = m.lang;
+		return builder;
+	}
+
 	@:allow(borogove)
 	private static function makeModerated(m: ChatMessage, timestamp: String, moderatorId: Null<String>, reason: Null<String>) {
 		final builder = new ChatMessageBuilder();
diff --git a/borogove/Client.hx b/borogove/Client.hx
index 292bbfb..6275091 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -1744,7 +1744,23 @@ class Client extends EventEmitter {
 
 	@:allow(borogove)
 	private function storeMessages(messages: Array<ChatMessage>): Promise<Array<ChatMessage>> {
-		return persistence.storeMessages(accountId(), messages);
+		return thenshim.PromiseTools.all(messages.map(m -> {
+			if (m.encryption?.status == DecryptionFailure && ((!m.isIncoming() && m.localId != null) || m.serverId != null)) {
+				return persistence.getMessage(accountId(), m.chatId(), m.isIncoming() ? m.serverId : null, m.localId).then(existing -> {
+					if (existing == null) return m;
+
+					trace("Merging failed decryyption with existing local copy", m, existing);
+					final b = ChatMessageBuilder.fromMessage(existing);
+					if (m.serverId != null) b.serverId = m.serverId;
+					if (m.serverIdBy != null) b.serverIdBy = m.serverIdBy;
+					b.syncPoint = m.syncPoint;
+					b.sortId = m.sortId;
+					b.status = m.status;
+					return b.build();
+				});
+			}
+			return thenshim.Promise.resolve(m);
+		})).then(updatedMessages -> persistence.storeMessages(accountId(), updatedMessages));
 	}
 
 	@:allow(borogove)
diff --git a/test/TestClient.hx b/test/TestClient.hx
index 0241ca2..4f02a01 100644
--- a/test/TestClient.hx
+++ b/test/TestClient.hx
@@ -146,6 +146,51 @@ class TestClient extends utest.Test {
 		});
 	}
 
+	public function testDecryptionFailurePreservesLocalMessage(async: Async) {
+		final persistence = new MessageMockPersistence();
+		final client = new Client("test@example.com", persistence);
+		final chatId = "chat@example.com";
+		client.getDirectChat(chatId);
+
+		final localBuilder = new ChatMessageBuilder({
+			localId: "local-123",
+			text: "good original text",
+			direction: MessageSent,
+			senderId: "test@example.com",
+			status: MessagePending
+		});
+		localBuilder.to = JID.parse(chatId);
+		final localMessage = localBuilder.build();
+
+		persistence.storeMessages(client.accountId(), [localMessage]).then(_ -> {
+			final incomingBuilder = new ChatMessageBuilder({
+				localId: "local-123",
+				serverId: "server-456",
+				serverIdBy: "server.com",
+				syncPoint: true,
+				direction: MessageSent,
+				senderId: "test@example.com",
+				status: MessageDeliveredToServer,
+				encryption: new borogove.EncryptionInfo(DecryptionFailure, "omemo"),
+				text: "failed decryption fallback"
+			});
+			incomingBuilder.sortId = "sort-1";
+			incomingBuilder.to = JID.parse(chatId);
+
+			client.storeMessages([incomingBuilder.build()]).then(stored -> {
+				Assert.equals(1, stored.length);
+				final m = stored[0];
+				Assert.equals("server-456", m.serverId);
+				Assert.equals("server.com", m.serverIdBy);
+				Assert.isTrue(m.syncPoint);
+				Assert.equals("sort-1", m.sortId);
+				Assert.equals(MessageDeliveredToServer, m.status);
+				Assert.equals("good original text", m.text);
+				async.done();
+			});
+		});
+	}
+
 	public function testDefaultDisplayName() {
 		final persistence = new Dummy();
 		final client = new Client("test@example.com", persistence);
@@ -714,12 +759,15 @@ class MessageMockPersistence extends Dummy {
 	override public function storeMessages(accountId: String, messages: Array<ChatMessage>): Promise<Array<ChatMessage>> {
 		for (m in messages) {
 			if (m.serverId != null) this.messages.set(m.serverId, m);
+			if (m.localId != null) this.messages.set(m.localId, m);
 		}
 		return Promise.resolve(messages);
 	}
 
 	override public function getMessage(accountId: String, chatId: String, serverId: Null<String>, localId: Null<String>): Promise<Null<ChatMessage>> {
-		return Promise.resolve(serverId != null ? messages.get(serverId) : null);
+		if (serverId != null && messages.exists(serverId)) return Promise.resolve(messages.get(serverId));
+		if (localId != null && messages.exists(localId)) return Promise.resolve(messages.get(localId));
+		return Promise.resolve(null);
 	}
 
 	override public function updateMessage(accountId: String, message: ChatMessage) {