git » sdk » commit 5f78a92

Allow encrypting to and decryting from MUCs

author Stephen Paul Weber
2026-07-06 20:15:02 UTC
committer Stephen Paul Weber
2026-07-06 20:15:02 UTC
parent 2f57c1366727f8a0eddd5e92470f2af8f05c00f4

Allow encrypting to and decryting from MUCs

borogove/Chat.hx +36 -1
borogove/OMEMO.hx +155 -151

diff --git a/borogove/Chat.hx b/borogove/Chat.hx
index 42a939b..ddf4070 100644
--- a/borogove/Chat.hx
+++ b/borogove/Chat.hx
@@ -1335,7 +1335,7 @@ class DirectChat extends Chat {
 			switch (e2eePreference) {
 			case OMEMO:
 				// As written this falls back to NoE2EE if allowed
-				return client.omemo.encryptMessage(JID.parse(counterpart), stanza).then((encryptedStanza) -> {
+				return client.omemo.encryptMessage([JID.parse(counterpart)], stanza).then((encryptedStanza) -> {
 					return Promise.resolve(encryptedStanza);
 				});
 			case NoE2EE:
@@ -2343,13 +2343,48 @@ class Channel extends Chat {
 		});
 	}
 
+	// TODO: this is a gross heuristic. Large private groups that don't want e2ee are possible for example
+	private function defaultE2ee() {
+		if (!client.outgoingE2EEPreference.exists(p -> p == NoE2EE)) {
+			// Must E2EE
+			return client.outgoingE2EEPreference[0];
+		}
+
+		if (!isPrivate()) return NoE2EE;
+
+		return client.outgoingE2EEPreference[0];
+	}
+
 	private function sendMessageStanza(stanza: Stanza, ?outboxItem: OutboxItem, e2eePreference: OutgoingE2EEPreference = Default) {
 		if (stanza.name != "message") throw "Can only send message stanza this way";
 
 		if (outboxItem == null) outboxItem = outbox.newItem();
 		stanza.attr.set("type", "groupchat");
 		stanza.attr.set("to", chatId);
+
+		// Default should try all preferences in order?
+		// Otherwise force to the one we were passed?
+		if (e2eePreference == Default) e2eePreference = defaultE2ee();
+		#if NO_OMEMO
+		if (e2eePreference != NoE2EE) throw "Selected E2EE preference unsupported";
 		outboxItem.handle(() -> client.sendStanza(stanza));
+		#else
+		switch (e2eePreference) {
+		case OMEMO:
+			// As written this falls back to NoE2EE if allowed and no one has OMEMO
+			// TODO: on some rooms, especially very large ones this may not be a full
+			// list of members. In practise it is fine anywhere OMEMO could actually work
+			members().then(members ->
+				client.omemo.encryptMessage(members.map(member -> JID.parse(member.chat?.chatId)), stanza)
+			).then((encryptedStanza) ->
+				outboxItem.handle(() -> client.sendStanza(encryptedStanza))
+			);
+		case NoE2EE:
+			outboxItem.handle(() -> client.sendStanza(stanza));
+		case Default:
+			throw "Impossible";
+		}
+		#end
 	}
 
 	@HaxeCBridge.noemit // on superclass as abstract
diff --git a/borogove/OMEMO.hx b/borogove/OMEMO.hx
index 4744b38..04ff28c 100644
--- a/borogove/OMEMO.hx
+++ b/borogove/OMEMO.hx
@@ -778,7 +778,7 @@ class OMEMO {
 		});
 	}
 
-	private function decryptPayload(deviceId:Int, deviceKey:OMEMOPayloadKey, fromBare:String, payload:OMEMOPayload):Promise<BytesData> {
+	private function decryptPayload(deviceId:Int, deviceKey:OMEMOPayloadKey, addr:SignalProtocolAddress, payload:OMEMOPayload):Promise<BytesData> {
 		var cipher:SessionCipher;
 		if(payload.getRawPayload() == null) {
 			// Probably a key transport message, which we don't
@@ -790,15 +790,15 @@ class OMEMO {
 				// Incoming message used a prekey - build a new session between
 				// us and the sender
 				trace("OMEMO: Received an encrypted message using a prekey. Creating session...");
-				final promSession = buildSession(deviceId, fromBare, payload.sid, "prekey");
+				final promSession = buildSession(deviceId, addr, "prekey");
 				promSession.then((session) -> {
-					getSessionCipher(deviceId, fromBare, payload.sid).then((cipher) -> {
+					getSessionCipher(deviceId, addr).then((cipher) -> {
 						resolve(cipher);
 					});
 				});
 			} else {
 				trace("OMEMO: Received message from existing session");
-				getSessionCipher(deviceId, fromBare, payload.sid).then((cipher) -> {
+				getSessionCipher(deviceId, addr).then((cipher) -> {
 					resolve(cipher);
 				});
 			}
@@ -828,14 +828,14 @@ class OMEMO {
 		return promPayload;
 	}
 
-	private function sendKeyExchange(deviceId:Int, jid:String, rid:Int) {
+	private function sendKeyExchange(deviceId:Int, addr:SignalProtocolAddress) {
 		trace("OMEMO: Preparing key exchange stanza...");
 		final emptyPayload = Bytes.alloc(32).toString();
 		final promEncryptedMessage = encryptPayloadWithNewKey(emptyPayload);
 
 		final promHeader = new Promise<Stanza>((resolve, reject) -> {
 			promEncryptedMessage.then((encryptionResult) -> {
-				buildOMEMOHeader(encryptionResult, deviceId, jid, [rid]).then(resolve, reject);
+				buildOMEMOHeader(encryptionResult, deviceId, [addr]).then(resolve, reject);
 			});
 		});
 
@@ -858,117 +858,124 @@ class OMEMO {
 		// Check for carbon-forwarded message
 		final from = stanza.attr.get("from") == null ? null : JID.parse(stanza.attr.get("from")).asBare();
 		final header = OMEMOPayload.fromMessageStanza(fwd??stanza);
-		final senderAddress = new SignalProtocolAddress(from.asString(), header.sid);
-		final sessionMeta = persistence.getOmemoMetadata(client.accountId(), senderAddress.toString());
-		final promDeviceId = client.omemo.getDeviceId();
-		var deviceKey:Null<OMEMOPayloadKey>;
-		final promResult = promDeviceId.then((deviceId:Int) -> {
-			if(deviceId == header.sid) {
-				// Message was sent by us (it was probably fetched from MAM)
-				// We're not going to build a session with ourself (that won't
-				// work!). We either have the original message locally, or we
-				// don't, but we can't decrypt this copy.
-				return Promise.resolve(
-					new OMEMODecryptionResult(
-						stanza,
-						new EncryptionInfo(
-							DecryptionFailure,
-							NS.OMEMO,
-							"own-message",
-							"Past message sent from this device (cannot be decrypted)"
+
+		final message = Message.fromStanza(stanza, client.jid);
+		return (client.getChat(message.chatId) ?? client.getDirectChat(message.chatId)).getMemberDetails([message.senderId]).then(senders -> {
+			final sender = senders[0] ?? throw "Sender not found";
+
+			return new SignalProtocolAddress(sender.chat?.chatId, header.sid);
+		}).then(senderAddress -> {
+			final sessionMeta = persistence.getOmemoMetadata(client.accountId(), senderAddress.toString());
+			final promDeviceId = client.omemo.getDeviceId();
+			var deviceKey:Null<OMEMOPayloadKey>;
+			final promResult = promDeviceId.then((deviceId:Int) -> {
+				if(deviceId == header.sid) {
+					// Message was sent by us (it was probably fetched from MAM)
+					// We're not going to build a session with ourself (that won't
+					// work!). We either have the original message locally, or we
+					// don't, but we can't decrypt this copy.
+					return Promise.resolve(
+						new OMEMODecryptionResult(
+							stanza,
+							new EncryptionInfo(
+								DecryptionFailure,
+								NS.OMEMO,
+								"own-message",
+								"Past message sent from this device (cannot be decrypted)"
+							)
 						)
-					)
-				);
-			}
-			deviceKey = header.findKey(deviceId);
-			if(deviceKey == null) {
-				trace("OMEMO: Message not encrypted for our device (looked for "+deviceId+")");
-				(fwd??stanza).removeChildren("encrypted", NS.OMEMO);
-				return Promise.resolve(
-					new OMEMODecryptionResult(
+					);
+				}
+				deviceKey = header.findKey(deviceId);
+				if(deviceKey == null) {
+					trace("OMEMO: Message not encrypted for our device (looked for "+deviceId+")");
+					(fwd??stanza).removeChildren("encrypted", NS.OMEMO);
+					return Promise.resolve(
+						new OMEMODecryptionResult(
+							stanza,
+							new EncryptionInfo(
+								DecryptionFailure,
+								NS.OMEMO,
+								"missing-key",
+								"Sender did not include this device in recipients"
+							)
+						)
+					);
+				}
+				// FIXME: Identify correct JID for group chats
+				trace("OMEMO: Decrypting payload...");
+				final promPayload = decryptPayload(deviceId, deviceKey, senderAddress, header);
+				return promPayload.then((decryptedPayload:BytesData) -> {
+					if(decryptedPayload == null) {
+						trace("OMEMO: Decrypted payload is null?");
+						return Promise.resolve(new OMEMODecryptionResult(
+							stanza,
+							new EncryptionInfo(
+								DecryptionFailure,
+								NS.OMEMO,
+								"invalid-payload",
+								"The encrypted message was malformed"
+							)
+						));
+					}
+
+					(fwd??stanza).removeChildren("body");
+					// FIXME: Verify valid UTF-8, etc.
+					(fwd??stanza).textTag("body", Bytes.ofData(decryptedPayload).toString());
+					trace("OMEMO: Payload decrypted OK!");
+					return Promise.resolve(new OMEMODecryptionResult(
 						stanza,
 						new EncryptionInfo(
-							DecryptionFailure,
+							DecryptionSuccess,
 							NS.OMEMO,
-							"missing-key",
-							"Sender did not include this device in recipients"
 						)
-					)
-				);
-			}
-			// FIXME: Identify correct JID for group chats
-			trace("OMEMO: Decrypting payload...");
-			final promPayload = decryptPayload(deviceId, deviceKey, from.asString(), header);
-			return promPayload.then((decryptedPayload:BytesData) -> {
-				if(decryptedPayload == null) {
-					trace("OMEMO: Decrypted payload is null?");
+					));
+				}, (err:Any) -> {
+					trace("OMEMO: Failed to decrypt message: " + err);
 					return Promise.resolve(new OMEMODecryptionResult(
 						stanza,
 						new EncryptionInfo(
 							DecryptionFailure,
 							NS.OMEMO,
-							"invalid-payload",
-							"The encrypted message was malformed"
+							"generic",
+							err,
 						)
 					));
-				}
-
-				(fwd??stanza).removeChildren("body");
-				// FIXME: Verify valid UTF-8, etc.
-				(fwd??stanza).textTag("body", Bytes.ofData(decryptedPayload).toString());
-				trace("OMEMO: Payload decrypted OK!");
-				return Promise.resolve(new OMEMODecryptionResult(
-					stanza,
-					new EncryptionInfo(
-						DecryptionSuccess,
-						NS.OMEMO,
-					)
-				));
-			}, (err:Any) -> {
-				trace("OMEMO: Failed to decrypt message: " + err);
-				return Promise.resolve(new OMEMODecryptionResult(
-					stanza,
-					new EncryptionInfo(
-						DecryptionFailure,
-						NS.OMEMO,
-						"generic",
-						err,
-					)
-				));
+				});
 			});
-		});
 
-		// Some post-decryption tasks, such as updating the session metadata
-		// and sending a key exchange if necessary
-		promResult.then((decryptionResult) -> {
-			sessionMeta.then((metadata) -> {
-				promDeviceId.then((deviceId) -> {
-					if(metadata == null) {
-						// No metadata in storage, so create a default
-						metadata = new OMEMOSessionMetadata(false, false, false);
-					}
-					final decryptedOk = decryptionResult.encryptionInfo.status == DecryptionSuccess;
-					var needUpdate = metadata.lastMessageDecryptedOk != decryptedOk;
-					final receivedSessionMessage = deviceKey != null && !deviceKey.prekey;
-					needUpdate = needUpdate || receivedSessionMessage != metadata.receivedSessionMessageOk;
-					// Send a key exchange if decryption failed, this wasn't a prekey message, and
-					// if we haven't already sent a key exchange
-					final shouldSendKeyExchange = !decryptedOk && receivedSessionMessage && !metadata.sentKeyExchange;
-					if(shouldSendKeyExchange) {
-						needUpdate = true;
-						trace("OMEMO: Possible broken session with <"+senderAddress.toString()+">, sending key exchange...");
-						buildSession(deviceId, from.asString(), header.sid, "replacement").then((session) -> {
-							sendKeyExchange(deviceId, from.asString(), header.sid);
-						});
-					}
-					if(needUpdate) {
-						persistence.storeOmemoMetadata(client.accountId(), senderAddress.toString(), new OMEMOSessionMetadata(receivedSessionMessage||metadata.receivedSessionMessageOk, decryptedOk, shouldSendKeyExchange));
-					}
+			// Some post-decryption tasks, such as updating the session metadata
+			// and sending a key exchange if necessary
+			promResult.then((decryptionResult) -> {
+				sessionMeta.then((metadata) -> {
+					promDeviceId.then((deviceId) -> {
+						if(metadata == null) {
+							// No metadata in storage, so create a default
+							metadata = new OMEMOSessionMetadata(false, false, false);
+						}
+						final decryptedOk = decryptionResult.encryptionInfo.status == DecryptionSuccess;
+						var needUpdate = metadata.lastMessageDecryptedOk != decryptedOk;
+						final receivedSessionMessage = deviceKey != null && !deviceKey.prekey;
+						needUpdate = needUpdate || receivedSessionMessage != metadata.receivedSessionMessageOk;
+						// Send a key exchange if decryption failed, this wasn't a prekey message, and
+						// if we haven't already sent a key exchange
+						final shouldSendKeyExchange = !decryptedOk && receivedSessionMessage && !metadata.sentKeyExchange;
+						if(shouldSendKeyExchange) {
+							needUpdate = true;
+							trace("OMEMO: Possible broken session with <"+senderAddress.toString()+">, sending key exchange...");
+							buildSession(deviceId, senderAddress, "replacement").then((session) -> {
+								sendKeyExchange(deviceId, senderAddress);
+							});
+						}
+						if(needUpdate) {
+							persistence.storeOmemoMetadata(client.accountId(), senderAddress.toString(), new OMEMOSessionMetadata(receivedSessionMessage||metadata.receivedSessionMessageOk, decryptedOk, shouldSendKeyExchange));
+						}
+					});
 				});
 			});
-		});
 
-		return promResult;
+			return promResult;
+		});
 	}
 
 	private function decryptPayloadWithKey(rawPayload:BytesData, rawKeyWithTag:BytesData, rawIv:BytesData):Promise<BytesData> {
@@ -1031,27 +1038,31 @@ class OMEMO {
 		final query = new PubsubGet(jid, node);
 		return new Promise<OMEMOBundle>((resolve, reject) -> {
 			query.onFinished(() -> {
-				resolve(bundleFromPubsubItems(query.getResult(), deviceId));
+				final bundle = bundleFromPubsubItems(query.getResult(), deviceId);
+				if (bundle == null) {
+					reject("No bundle fetched for " + jid);
+				} else {
+					resolve(bundle);
+				}
 			});
 			client.sendQuery(query);
 		});
 	}
 
-	private function getContactDevices(jid:JID):Promise<Array<Int>> {
+	private function getContactDevices(jid:JID):Promise<Array<SignalProtocolAddress>> {
 		final jidBareStr = jid.asBare().asString();
+		// FIXME: Use local storage
+		var chat = client.getDirectChat(jidBareStr, false);
+		if(chat.omemoContactDeviceIDs != null) {
+			return Promise.resolve(chat.omemoContactDeviceIDs.map(rid -> new SignalProtocolAddress(jidBareStr, rid)));
+		}
 		return new Promise((resolve, reject) -> {
-			// FIXME: Use local storage
-			var chat = client.getDirectChat(jidBareStr, false);
-			if(chat.omemoContactDeviceIDs != null) {
-				resolve(chat.omemoContactDeviceIDs);
-				return;
-			}
 			final deviceListGet = new PubsubGet(jidBareStr, "eu.siacs.conversations.axolotl.devicelist");
 			deviceListGet.onFinished(() -> {
 				final devices = deviceIdsFromPubsubItems(deviceListGet.getResult());
 				if(devices != null) {
 					chat.omemoContactDeviceIDs = devices;
-					resolve(devices);
+					resolve(devices.map(rid -> new SignalProtocolAddress(jidBareStr, rid)));
 				} else {
 					chat.omemoContactDeviceIDs = [];
 					reject("no-devices");
@@ -1075,26 +1086,22 @@ class OMEMO {
 		});
 	}
 
-	public function encryptMessage(recipient:JID, stanza:Stanza):Promise<Stanza> {
-		final promEncryptedMessage = encryptPayloadWithNewKey(stanza.getChildText("body"));
+	public function encryptMessage(recipients: Array<JID>, stanza:Stanza):Promise<Stanza> {
+		final promHeader = thenshim.PromiseTools.all(recipients.map(getContactDevices)).then(raddresses -> {
+			final raddrs = raddresses.flatMap(addrs -> addrs);
 
-		final promDeviceId = this.getDeviceId();
-
-		final promRecipientDevices = getContactDevices(recipient);
+			if(raddrs.length == 0) {
+				return Promise.reject("no-devices");
+			}
 
-		final promHeader = new Promise<Stanza>((resolve, reject) -> {
-			promDeviceId.then((deviceId) -> {
-				promRecipientDevices.then((recipientDevices) -> {
-					if(recipientDevices.length == 0) {
-						reject("no-devices");
-						return;
-					}
-					promEncryptedMessage.then((encryptionResult) -> {
-						trace("OMEMO: Encrypting for recipient devices: " + recipientDevices.toString());
-						buildOMEMOHeader(encryptionResult, deviceId, recipient.asString(), recipientDevices).then(resolve, reject);
-					}, reject);
-				}, reject);
-			}, reject);
+			return getDeviceId().then(deviceId ->
+				encryptPayloadWithNewKey(stanza.getChildText("body")).then(encryptionResult -> ({
+					raddrs: raddrs, deviceId: deviceId, encryptionResult: encryptionResult
+				}))
+			);
+		}).then((d) -> {
+			trace("OMEMO: Encrypting for recipients: " + d.raddrs);
+			return buildOMEMOHeader(d.encryptionResult, d.deviceId, d.raddrs);
 		});
 
 		final promStanza = promHeader.then((header) -> {
@@ -1138,14 +1145,13 @@ class OMEMO {
 		return promStanza;
 	}
 
-	private function buildSession(sid:Int, jid:String, rid:Int, reason:String):Promise<SignalSession> {
-		final address = new SignalProtocolAddress(jid, rid);
-		final promBundle = getContactBundle(jid, rid);
-		trace("OMEMO: Building session for <"+address.toString()+"> for "+reason+" (fetching bundle)...");
+	private function buildSession(sid:Int, addr:SignalProtocolAddress, reason:String):Promise<SignalSession> {
+		final promBundle = getContactBundle(addr.getName(), addr.getDeviceId());
+		trace("OMEMO: Building session for <"+addr.toString()+"> for "+reason+" (fetching bundle)...");
 		final promSession = promBundle.then((bundle:OMEMOBundle) -> {
 			trace("OMEMO: Fetched bundle");
 			final contactPreKey = bundle.getRandomPreKey();
-			return new SessionBuilder(signalStore, address).processPreKey({
+			return new SessionBuilder(signalStore, addr).processPreKey({
 				registrationId: sid,
 				identityKey: Base64.decode(bundle.identity_key).getData(),
 				signedPreKey: {
@@ -1159,33 +1165,31 @@ class OMEMO {
 				},
 			});
 		}).then((_) -> {
-			trace("OMEMO: Built session! ("+address.toString()+" for "+reason+")");
-			return signalStore.loadSession(address);
+			trace("OMEMO: Built session! ("+addr.toString()+" for "+reason+")");
+			return signalStore.loadSession(addr);
 		}, (err:Any) -> {
-			trace("OMEMO: Failed to build "+reason+" session for <"+address.toString()+">: "+err);
-			return signalStore.loadSession(address);
+			trace("OMEMO: Failed to build "+reason+" session for <"+addr.toString()+">: "+err);
+			return signalStore.loadSession(addr);
 		});
 
 		return promSession;
 	}
 
-	private function getSessionCipher(sid:Int, jid:String, rid:Int):Promise<SessionCipher> {
-		final address = new SignalProtocolAddress(jid, rid);
-
+	private function getSessionCipher(sid:Int, addr:SignalProtocolAddress):Promise<SessionCipher> {
 		// Load or start a session
-		return signalStore.loadSession(address).then((session) -> {
+		return signalStore.loadSession(addr).then((session) -> {
 			if(session == null) {
-				trace("OMEMO: No session for "+address.toString());
-				return buildSession(sid, jid, rid, "new");
+				trace("OMEMO: No session for "+addr.toString());
+				return buildSession(sid, addr, "new");
 			}
 			return session;
 		}).then((session) -> {
-			return new SessionCipher(signalStore, address);
+			return new SessionCipher(signalStore, addr);
 		});
 	}
 
 	private function getRecipientSessions(sid:Int, jid:String, deviceList:Array<Int>):Promise<Array<SessionCipher>> {
-		return PromiseTools.all(deviceList.map(rid -> getSessionCipher(sid, jid, rid)));
+		return PromiseTools.all(deviceList.map(rid -> getSessionCipher(sid, new SignalProtocolAddress(jid, rid))));
 	}
 
 	private function encryptPayloadKeyForSession(encryptionResult:OMEMOEncryptionResult, sessionCipher:SessionCipher):Promise<SignalCipherText> {
@@ -1206,19 +1210,19 @@ class OMEMO {
 		#end
 	}
 
-	private function encryptForDevice(sid:Int, jid:String, rid:Int, encryptionResult:OMEMOEncryptionResult):Promise<OMEMOPayloadKey> {
-		return getSessionCipher(sid, jid, rid).then((sessionCipher) ->
+	private function encryptForDevice(sid:Int, addr:SignalProtocolAddress, encryptionResult:OMEMOEncryptionResult):Promise<OMEMOPayloadKey> {
+		return getSessionCipher(sid, addr).then((sessionCipher) ->
 			encryptPayloadKeyForSession(encryptionResult, sessionCipher)
 		).then((encryptedKey) ->
 				({
-					rid: rid,
+					rid: addr.getDeviceId(),
 					prekey: encryptedKey.type == 3,
 					encodedKey: b64EncodeKey(encryptedKey.body),
 				} : OMEMOPayloadKey)
 		);
 	}
 
-	private function buildOMEMOHeader(encryptionResult:OMEMOEncryptionResult, sid:Int, jid:String, deviceList:Array<Int>):Promise<Stanza> {
+	private function buildOMEMOHeader(encryptionResult:OMEMOEncryptionResult, sid:Int, raddrs:Array<SignalProtocolAddress>):Promise<Stanza> {
 		// We'll include keys for our contact's devices, but we also need
 		// to include any of our own devices, so they can read the outgoing
 		// message
@@ -1228,12 +1232,12 @@ class OMEMO {
 			for(rid in ownDeviceList) {
 				// Don't encrypt to our own device (we already have the original message locally)
 				if(sid != rid) {
-					keys.push(encryptForDevice(sid, this.client.accountId(), rid, encryptionResult));
+					keys.push(encryptForDevice(sid, new SignalProtocolAddress(this.client.accountId(), rid), encryptionResult));
 				}
 			}
 
-			for(rid in deviceList) {
-				keys.push(encryptForDevice(sid, jid, rid, encryptionResult));
+			for(raddr in raddrs) {
+				keys.push(encryptForDevice(sid, raddr, encryptionResult));
 			}
 
 			// Return an array of promises which each resolve to an OMEMOPayloadKey