| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-07-01 02:44:41 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-07-01 02:44:41 UTC |
| parent | ce87df2ce1d34634d49010d383244e9ece36662b |
| HaxeSwiftBridge.hx | +2 | -0 |
| borogove/Chat.hx | +32 | -30 |
| borogove/Client.hx | +59 | -11 |
| borogove/EncryptionInfo.hx | +4 | -4 |
| borogove/EncryptionPolicy.hx | +0 | -15 |
| borogove/OMEMO.hx | +4 | -3 |
| borogove/persistence/IDB.js | +1 | -0 |
| browserjs.hxml | +0 | -1 |
| nodejs.hxml | +0 | -1 |
| npm/index.ts | +2 | -0 |
| test/TestChat.hx | +48 | -0 |
| test/TestClient.hx | +49 | -0 |
| testjs.hxml | +0 | -1 |
diff --git a/HaxeSwiftBridge.hx b/HaxeSwiftBridge.hx index bb781a1..4dd452f 100644 --- a/HaxeSwiftBridge.hx +++ b/HaxeSwiftBridge.hx @@ -236,6 +236,8 @@ class HaxeSwiftBridge { case EConst(CInt(i, null)): builder.add(" = "); builder.add(Std.string(i)); + case EConst(CIdent(id)) if (id == "Default"): + builder.add(" = 0"); case null: builder.add(" = nil"); default: diff --git a/borogove/Chat.hx b/borogove/Chat.hx index 025f755..42a939b 100644 --- a/borogove/Chat.hx +++ b/borogove/Chat.hx @@ -91,16 +91,17 @@ enum abstract UserState(Int) { } /** - Describes the current encryption mode of the conversation. - - This mode is a high-level representation of the user/app *intent* - for the current conversation - e.g. not a guarantee that incoming - messages will always match this expectation. It is used to determine - the logic for outgoing messages, though. + End-to-End Encryption preferences for outgoing messages. **/ -enum abstract EncryptionMode(Int) { - var Unencrypted; // No end-to-end encryption - var EncryptedOMEMO; // Use OMEMO +enum abstract OutgoingE2EEPreference(Int) { + /** Use the client's default allowed E2EE method. **/ + var Default = 0; + /** Send the message without any End-to-End Encryption. **/ + var NoE2EE; +#if !NO_OMEMO + /** Use OMEMO for End-to-End Encryption. **/ + var OMEMO; +#end } @:expose @@ -161,7 +162,6 @@ abstract class Chat extends EventEmitter { private var activeThread: Null<String> = null; private var notificationSettings: Null<{reply: Bool, mention: Bool}> = null; private var outbox = new Outbox(); - private var _encryptionMode: EncryptionMode = Unencrypted; @:allow(borogove) private var omemoContactDeviceIDs: Null<Array<Int>> = null; @@ -253,10 +253,11 @@ abstract class Chat extends EventEmitter { Send a message to this Chat @param message the ChatMessageBuilder to send + @param e2eePreference optional explicit End-to-End Encryption preference for this message. If Default, uses the client's preferred allowed E2EE method. **/ - abstract public function sendMessage(message:ChatMessageBuilder):Void; + abstract public function sendMessage(message:ChatMessageBuilder, e2eePreference:OutgoingE2EEPreference = Default):Void; - abstract private function sendMessageStanza(stanza: Stanza, ?outboxItem: OutboxItem):Void; + abstract private function sendMessageStanza(stanza: Stanza, ?outboxItem: OutboxItem, e2eePreference: OutgoingE2EEPreference = Default):Void; /** Signals that all messages up to and including this one have probably @@ -852,17 +853,6 @@ abstract class Chat extends EventEmitter { return jingleSessions.flatMap((session) -> session.videoTracks()); } #end - /** - Get encryption mode for this chat - **/ - public function encryptionMode(): String { - switch(_encryptionMode) { - case Unencrypted: - return "unencrypted"; - case EncryptedOMEMO: - return "omemo"; - } - } /** Can the user send messages to this chat? @@ -1248,7 +1238,7 @@ class DirectChat extends Chat { } @HaxeCBridge.noemit // on superclass as abstract - public function sendMessage(message: ChatMessageBuilder):Void { + public function sendMessage(message: ChatMessageBuilder, e2eePreference: OutgoingE2EEPreference = Default):Void { if (uiState == Invited) uiState = Open; if (typingTimer != null) typingTimer.stop(); client.chatActivity(this); @@ -1265,7 +1255,7 @@ class DirectChat extends Chat { activeThread = message.threadId; stanza.tag("active", { xmlns: "http://jabber.org/protocol/chatstates" }).up(); } - sendMessageStanza(stanza, outboxItem); + sendMessageStanza(stanza, outboxItem, e2eePreference); setLastMessage(stored).then(_ -> { client.notifyMessageHandlers(stored, stored.versions.length > 1 ? CorrectionEvent : DeliveryEvent); client.trigger("chats/update", [this]); @@ -1318,7 +1308,7 @@ class DirectChat extends Chat { }); } - private function sendMessageStanza(stanza: Stanza, ?outboxItem: OutboxItem) { + 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(); @@ -1335,12 +1325,24 @@ class DirectChat extends Chat { addresses.up(); } + // Default should try all preferences in order? + // Otherwise force to the one we were passed? + if (e2eePreference == Default) e2eePreference = client.outgoingE2EEPreference[0]; #if NO_OMEMO + if (e2eePreference != NoE2EE) throw "Selected E2EE preference unsupported"; return Promise.resolve(stanza); #else - return client.omemo.encryptMessage(JID.parse(counterpart), stanza).then((encryptedStanza) -> { + switch (e2eePreference) { + case OMEMO: + // As written this falls back to NoE2EE if allowed + return client.omemo.encryptMessage(JID.parse(counterpart), stanza).then((encryptedStanza) -> { + return Promise.resolve(encryptedStanza); + }); + case NoE2EE: return Promise.resolve(stanza); - }); + case Default: + throw "Impossible"; + } #end })).then(stanzas -> { outboxItem.handle(() -> { @@ -2271,7 +2273,7 @@ class Channel extends Chat { } @HaxeCBridge.noemit // on superclass as abstract - public function sendMessage(message:ChatMessageBuilder):Void { + public function sendMessage(message:ChatMessageBuilder, e2eePreference:OutgoingE2EEPreference = Default):Void { if (uiState == Invited) uiState = Open; if (typingTimer != null) typingTimer.stop(); client.chatActivity(this); @@ -2341,7 +2343,7 @@ class Channel extends Chat { }); } - private function sendMessageStanza(stanza: Stanza, ?outboxItem: OutboxItem) { + 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(); diff --git a/borogove/Client.hx b/borogove/Client.hx index 0a339f2..292bbfb 100644 --- a/borogove/Client.hx +++ b/borogove/Client.hx @@ -11,7 +11,6 @@ import borogove.Chat; import borogove.ChatMessage; import borogove.Message; import borogove.EventEmitter; -import borogove.EncryptionPolicy; #if !NO_OMEMO import borogove.OMEMO; #end @@ -65,6 +64,18 @@ class Client extends EventEmitter { **/ @:allow(borogove) public var sendAvailable(null, default): Bool = true; + /** + If true, the client will block and return a policy-violation error for + any incoming chat message that is not end-to-end encrypted. + **/ + public var blockIncomingWithoutE2EE: Bool = false; + @:allow(borogove) + private var outgoingE2EEPreference: Array<OutgoingE2EEPreference> = [ + NoE2EE, + #if !NO_OMEMO + OMEMO + #end + ]; private var stream:GenericStream; @:allow(borogove) private var jid(default,null):JID; @@ -108,12 +119,6 @@ class Client extends EventEmitter { private var sortId: String = "a "; private final pendingCaps: Map<String, Array<(Null<Caps>)->Chat>> = []; private final brokenAvatars: Map<String, JID> = []; - @:allow(borogove) - private final encryptionPolicy:EncryptionPolicy = { - allowUnencryptedOutgoing: true, - allowUnencryptedIncoming: true, - preferEncryptedOutgoing: true, - }; #if !NO_OMEMO @:allow(borogove) @@ -209,7 +214,7 @@ class Client extends EventEmitter { } #if !NO_OMEMO - if((fwd??stanza).hasChild("encrypted", NS.OMEMO)) { + if((fwd??stanza).hasChild("encrypted", NS.OMEMO) && omemo != null) { omemo.decryptMessage(stanza, fwd).then((decryptionResult) -> { trace("OMEMO: Decrypted message, now processing..."); processLiveMessage(decryptionResult.stanza, fwd, decryptionResult.encryptionInfo); @@ -218,6 +223,7 @@ class Client extends EventEmitter { return EventHandled; } #end + processLiveMessage(stanza, fwd); return EventHandled; }); @@ -482,6 +488,17 @@ class Client extends EventEmitter { switch (message.parsed) { case ChatMessageStanza(chatMessage): + if (blockIncomingWithoutE2EE && message.encryption == null) { + // return error for disallowed no-e2ee incoming chat message + sendStanza( + new Stanza("message", { type: "error", id: stanza.attr.get("id"), to: stanza.attr.get("from") }) + .tag("error", { by: jid.asString(), type: "cancel" }) + .tag("policy-violation", { xmlns: "urn:ietf:params:xml:ns:xmpp-stanzas" }).up() + .textTag("text", "E2EE Required", { xmlns: "urn:ietf:params:xml:ns:xmpp-stanzas" }) + ); + return; + } + for (hash in chatMessage.inlineHashReferences()) { fetchMediaByHash([hash], [chatMessage.from]); } @@ -733,7 +750,7 @@ class Client extends EventEmitter { trace("pubsubNode == "+pubsubNode); #if !NO_OMEMO - if(pubsubNode == "eu.siacs.conversations.axolotl.devicelist") { + if(pubsubNode == "eu.siacs.conversations.axolotl.devicelist" && omemo != null) { if(isOwnAccount) { omemo.onAccountUpdatedDeviceList(pubsubEvent.getItems()); } else { @@ -988,6 +1005,27 @@ class Client extends EventEmitter { return true; } + /** + Adjusts the outgoing E2EE preference list to prioritize the specified preference. + This ensures that the client will attempt to use this E2EE protocol first when sending messages. + + @param preference the OutgoingE2EEPreference to prioritize + **/ + public function preferE2ee(preference: OutgoingE2EEPreference) { + outgoingE2EEPreference = outgoingE2EEPreference.filter(p -> p != preference); + outgoingE2EEPreference.unshift(preference); + } + + /** + Removes the specified E2EE preference from the allowed outgoing preferences. + The client will no longer use this protocol for outgoing messages. + + @param preference the OutgoingE2EEPreference to ban + **/ + public function banE2ee(preference: OutgoingE2EEPreference) { + outgoingE2EEPreference = outgoingE2EEPreference.filter(p -> p != preference); + } + private function onConnected(data) { // Fired on connect or reconnect if (data != null && data.jid != null) { jid = JID.parse(data.jid); @@ -2061,8 +2099,18 @@ class Client extends EventEmitter { for (m in messageList.messages) { switch (m.parsed) { case ChatMessageStanza(message): - chatMessages.push(message); - if (message.type == MessageChat) chatIds[message.chatId()] = true; + if (blockIncomingWithoutE2EE && m.encryption == null) { + // return error for disallowed no-e2ee incoming chat message + sendStanza( + new Stanza("message", { type: "error", id: message.localId, to: message.from.asString() }) + .tag("error", { by: jid.asString(), type: "cancel" }) + .tag("policy-violation", { xmlns: "urn:ietf:params:xml:ns:xmpp-stanzas" }).up() + .textTag("text", "E2EE Required", { xmlns: "urn:ietf:params:xml:ns:xmpp-stanzas" }) + ); + } else { + chatMessages.push(message); + if (message.type == MessageChat) chatIds[message.chatId()] = true; + } case ReactionUpdateStanza(update): promises.push( persistence.storeReaction(accountId(), update).then(_ -> null) diff --git a/borogove/EncryptionInfo.hx b/borogove/EncryptionInfo.hx index 358eaa7..e8986b3 100644 --- a/borogove/EncryptionInfo.hx +++ b/borogove/EncryptionInfo.hx @@ -35,10 +35,10 @@ class EncryptionInfo { ]; @:allow(borogove) - private function new(status:EncryptionStatus, method:String, ?methodName:String, ?reason:String, ?reasonText:String) { + private function new(status:EncryptionStatus, method:String, ?reason:String, ?reasonText:String, ?methodName:String) { this.status = status; this.method = method; - this.methodName = methodName; + this.methodName = methodName ?? knownEncryptionSchemes.get(method); this.reason = reason; this.reasonText = reasonText; } @@ -79,9 +79,9 @@ class EncryptionInfo { return new EncryptionInfo( DecryptionFailure, ns??"unknown", - knownEncryptionSchemes.get(ns)??name??"Unknown encryption", "unsupported-encryption", - "Unsupported encryption method: "+(name??ns) + "Unsupported encryption method: "+(name??ns), + knownEncryptionSchemes.get(ns)??name??"Unknown encryption" ); } return null; // Probably not encrypted diff --git a/borogove/EncryptionPolicy.hx b/borogove/EncryptionPolicy.hx deleted file mode 100644 index 9f0a9b0..0000000 --- a/borogove/EncryptionPolicy.hx +++ /dev/null @@ -1,15 +0,0 @@ -package borogove; - -@:structInit -class EncryptionPolicy { - // These allow blocking all incoming/outgoing - // chat messages which are not using E2EE - public final allowUnencryptedIncoming:Bool; - public final allowUnencryptedOutgoing:Bool; - - // Outgoing encryption will be preferred, - // but may not be used if a recipient lacks - // support (and of course if the above - // policies permit unencrypted) - public final preferEncryptedOutgoing:Bool; -} diff --git a/borogove/OMEMO.hx b/borogove/OMEMO.hx index 3cdb0e8..3ed722b 100644 --- a/borogove/OMEMO.hx +++ b/borogove/OMEMO.hx @@ -16,6 +16,7 @@ import thenshim.Promise; import thenshim.PromiseTools; using borogove.SignalProtocol; +using Lambda; #if js import js.Browser; @@ -1108,7 +1109,7 @@ class OMEMO { return newStanza; }, (failureReason) -> { final noRecipientSupport = failureReason == "no-devices"; - var allowUnencrypted:Bool = client.encryptionPolicy.allowUnencryptedOutgoing; + var allowUnencrypted:Bool = client.outgoingE2EEPreference.exists(p -> p == NoE2EE); var errMsg:String; if(noRecipientSupport) { @@ -1120,7 +1121,7 @@ class OMEMO { // encrypted communication *is* preferred, we need a good excuse to // send unencrypted (such as no recipient support), but no such excuse // is found here. - allowUnencrypted = allowUnencrypted && !client.encryptionPolicy.preferEncryptedOutgoing; + allowUnencrypted = allowUnencrypted && client.outgoingE2EEPreference[0] == NoE2EE; } if(!allowUnencrypted) { @@ -1213,7 +1214,7 @@ class OMEMO { // to a pair of bytes (since JS uses UTF-16). return Browser.window.btoa(keyStr); #else - return Base64.encode(bytesOfString(keyStr, RawNative)); + return Base64.encode(bytesOfString(keyStr)); #end } diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js index e63d50b..1ae627b 100644 --- a/borogove/persistence/IDB.js +++ b/borogove/persistence/IDB.js @@ -343,6 +343,7 @@ export default async (dbname, media, tokenize, stemmer) => { message.lang = value.lang; message.type = value.type || (value.isGroupchat || value.groupchat ? enums.borogove_MessageType.Channel : enums.borogove_MessageType.Chat); message.payloads = (value.payloads || []).map(borogove_Stanza.parse); + message.encryption = value.encryption; message.stanza = value.stanza && borogove_Stanza.parse(value.stanza); if (!message.localId && !message.serverId) message.localId = "NO_ID"; // bad data return message.build(); diff --git a/browserjs.hxml b/browserjs.hxml index 942e9d8..170a7b1 100644 --- a/browserjs.hxml +++ b/browserjs.hxml @@ -23,5 +23,4 @@ borogove.Import -D hxtsdgen_enums_ts -D js_global=globalThis -D js-classic --D NO_OMEMO --js npm/borogove-browser.js diff --git a/nodejs.hxml b/nodejs.hxml index cbc4efe..23aa506 100644 --- a/nodejs.hxml +++ b/nodejs.hxml @@ -26,5 +26,4 @@ borogove.Import -D hxtsdgen_enums_ts -D js_global=globalThis -D js-classic --D NO_OMEMO --js npm/borogove.js diff --git a/npm/index.ts b/npm/index.ts index 3224dcf..a7095e7 100644 --- a/npm/index.ts +++ b/npm/index.ts @@ -1,8 +1,10 @@ export { borogove_ChatMessageEvent as ChatMessageEvent, + borogove_EncryptionStatus as EncryptionStatus, borogove_MessageDirection as MessageDirection, borogove_MessageStatus as MessageStatus, borogove_MessageType as MessageType, + borogove_OutgoingE2EEPreference as OutgoingE2EEPreference, borogove_ReactionUpdateKind as ReactionUpdateKind, borogove_UiState as UiState, borogove_UserState as UserState, diff --git a/test/TestChat.hx b/test/TestChat.hx index f816fdf..75745d3 100644 --- a/test/TestChat.hx +++ b/test/TestChat.hx @@ -735,6 +735,54 @@ class TestChat extends utest.Test { chat.voiceRequestRespond(member, true); chat.voiceRequestRespond(member, false); } + + public function testSendMessageNoE2EE(async: Async) { + final persistence = new Dummy(); + final client = new Client("test@example.com", persistence); + final chat = client.getDirectChat("friend@example.com"); + + client.stream.on("sendStanza", (stanza: Stanza) -> { + if (stanza.name == "message" && stanza.getChild("encrypted", "eu.siacs.conversations.axolotl") == null) { + Assert.equals("hello", stanza.getChildText("body")); + async.done(); + return EventHandled; + } + return EventUnhandled; + }); + + final builder = new ChatMessageBuilder(); + builder.text = "hello"; + chat.sendMessage(builder, borogove.Chat.OutgoingE2EEPreference.NoE2EE); + } + +#if js + public function testSendMessageOMEMO(async: Async) { + final persistence = new Dummy(); + final client = new Client("test@example.com", persistence); + final chat = client.getDirectChat("friend@example.com"); + + Reflect.setField(client, "omemo", { + encryptMessage: function(jid: borogove.JID, stanza: Stanza) { + final encrypted = stanza.clone(); + encrypted.tag("encrypted", { xmlns: "eu.siacs.conversations.axolotl" }).up(); + return Promise.resolve(encrypted); + } + }); + + client.stream.on("sendStanza", (stanza: Stanza) -> { + if (stanza.name == "message" && stanza.getChild("encrypted", "eu.siacs.conversations.axolotl") != null) { + Assert.notNull(stanza.getChild("encrypted", "eu.siacs.conversations.axolotl")); + async.done(); + return EventHandled; + } + return EventUnhandled; + }); + + final builder = new ChatMessageBuilder(); + builder.text = "hello"; + chat.sendMessage(builder, borogove.Chat.OutgoingE2EEPreference.OMEMO); + } +#end } @:access(borogove) diff --git a/test/TestClient.hx b/test/TestClient.hx index 2f006a3..0241ca2 100644 --- a/test/TestClient.hx +++ b/test/TestClient.hx @@ -17,6 +17,7 @@ import borogove.Role; import borogove.Stanza; import borogove.Status; import borogove.persistence.Dummy; +import borogove.Chat.OutgoingE2EEPreference; using Lambda; @@ -637,6 +638,54 @@ class TestClient extends utest.Test { async.done(); }); } + + public function testPreferE2ee() { + final persistence = new Dummy(); + final client = new Client("test@example.com", persistence); + client.outgoingE2EEPreference = []; + client.preferE2ee(OutgoingE2EEPreference.NoE2EE); + Assert.equals(OutgoingE2EEPreference.NoE2EE, client.outgoingE2EEPreference[0]); + } + + public function testBanE2ee() { + final persistence = new Dummy(); + final client = new Client("test@example.com", persistence); + client.banE2ee(OutgoingE2EEPreference.NoE2EE); + Assert.isFalse(client.outgoingE2EEPreference.contains(OutgoingE2EEPreference.NoE2EE)); + } + + public function testBlockIncomingWithoutE2EE(async: Async) { + final persistence = new Dummy(); + final client = new Client("test@example.com", persistence); + client.blockIncomingWithoutE2EE = true; + + client.stream.on("sendStanza", (stanza: Stanza) -> { + if (stanza.name == "message" && stanza.attr.get("type") == "error") { + Assert.equals("friend@example.com", stanza.attr.get("to")); + final error = stanza.getChild("error"); + Assert.notNull(error); + Assert.equals("cancel", error.attr.get("type")); + Assert.notNull(error.getChild("policy-violation", "urn:ietf:params:xml:ns:xmpp-stanzas")); + async.done(); + return EventHandled; + } + return EventUnhandled; + }); + + client.stream.onStanza( + new Stanza("message", { xmlns: "jabber:client", from: "friend@example.com", id: "msg1" }) + .textTag("body", "hello without e2ee") + ); + } + +#if js + public function testPreferOMEMO() { + final persistence = new Dummy(); + final client = new Client("test@example.com", persistence); + client.preferE2ee(OutgoingE2EEPreference.OMEMO); + Assert.equals(OutgoingE2EEPreference.OMEMO, client.outgoingE2EEPreference[0]); + } +#end } @:access(borogove) diff --git a/testjs.hxml b/testjs.hxml index a095639..84c3dc4 100644 --- a/testjs.hxml +++ b/testjs.hxml @@ -17,7 +17,6 @@ -D analyzer-optimize -D js-es=6 -D js_global=globalThis --D NO_OMEMO -D no-traces -main test.TestAll -w -WDeprecated