Mercurial > libervia-backend
comparison sat/plugins/plugin_xep_0374.py @ 3933:cecf45416403
plugin XEP-0373 and XEP-0374: Implementation of OX and OXIM:
GPGME is used as the GPG provider.
rel 374
author | Syndace <me@syndace.dev> |
---|---|
date | Tue, 20 Sep 2022 16:22:18 +0200 |
parents | |
children | 80d29f55ba8b |
comparison
equal
deleted
inserted
replaced
3932:7af29260ecb8 | 3933:cecf45416403 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # Libervia plugin for OpenPGP for XMPP Instant Messaging | |
4 # Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev) | |
5 | |
6 # This program is free software: you can redistribute it and/or modify | |
7 # it under the terms of the GNU Affero General Public License as published by | |
8 # the Free Software Foundation, either version 3 of the License, or | |
9 # (at your option) any later version. | |
10 | |
11 # This program is distributed in the hope that it will be useful, | |
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
14 # GNU Affero General Public License for more details. | |
15 | |
16 # You should have received a copy of the GNU Affero General Public License | |
17 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
18 | |
19 from typing import Dict, Optional, Set, cast | |
20 | |
21 from typing_extensions import Final | |
22 from wokkel import muc # type: ignore[import] | |
23 | |
24 from sat.core import exceptions | |
25 from sat.core.constants import Const as C | |
26 from sat.core.core_types import SatXMPPEntity | |
27 from sat.core.i18n import _, D_ | |
28 from sat.core.log import getLogger, Logger | |
29 from sat.core.sat_main import SAT | |
30 from sat.core.xmpp import SatXMPPClient | |
31 from sat.plugins.plugin_xep_0045 import XEP_0045 | |
32 from sat.plugins.plugin_xep_0334 import XEP_0334 | |
33 from sat.plugins.plugin_xep_0373 import NS_OX, XEP_0373, TrustLevel | |
34 from sat.tools import xml_tools | |
35 from twisted.internet import defer | |
36 from twisted.words.protocols.jabber import jid | |
37 from twisted.words.xish import domish | |
38 | |
39 | |
40 __all__ = [ # pylint: disable=unused-variable | |
41 "PLUGIN_INFO", | |
42 "XEP_0374", | |
43 "NS_OXIM" | |
44 ] | |
45 | |
46 | |
47 log = cast(Logger, getLogger(__name__)) # type: ignore[no-untyped-call] | |
48 | |
49 | |
50 PLUGIN_INFO = { | |
51 C.PI_NAME: "OXIM", | |
52 C.PI_IMPORT_NAME: "XEP-0374", | |
53 C.PI_TYPE: "SEC", | |
54 C.PI_PROTOCOLS: [ "XEP-0374" ], | |
55 C.PI_DEPENDENCIES: [ "XEP-0334", "XEP-0373" ], | |
56 C.PI_RECOMMENDATIONS: [ "XEP-0045" ], | |
57 C.PI_MAIN: "XEP_0374", | |
58 C.PI_HANDLER: "no", | |
59 C.PI_DESCRIPTION: _("""Implementation of OXIM"""), | |
60 } | |
61 | |
62 | |
63 # The disco feature | |
64 NS_OXIM: Final = "urn:xmpp:openpgp:im:0" | |
65 | |
66 | |
67 class XEP_0374: | |
68 """ | |
69 Plugin equipping Libervia with OXIM capabilities under the ``urn:xmpp:openpgp:im:0`` | |
70 namespace. MUC messages are supported next to one to one messages. For trust | |
71 management, the two trust models "BTBV" and "manual" are supported. | |
72 """ | |
73 | |
74 def __init__(self, sat: SAT) -> None: | |
75 """ | |
76 @param sat: The SAT instance. | |
77 """ | |
78 | |
79 self.__sat = sat | |
80 | |
81 # Plugins | |
82 self.__xep_0045 = cast(Optional[XEP_0045], sat.plugins.get("XEP-0045")) | |
83 self.__xep_0334 = cast(XEP_0334, sat.plugins["XEP-0334"]) | |
84 self.__xep_0373 = cast(XEP_0373, sat.plugins["XEP-0373"]) | |
85 | |
86 # Triggers | |
87 sat.trigger.add( | |
88 "messageReceived", | |
89 self.__message_received_trigger, | |
90 priority=100050 | |
91 ) | |
92 sat.trigger.add("send", self.__send_trigger, priority=0) | |
93 | |
94 # Register the encryption plugin | |
95 sat.registerEncryptionPlugin(self, "OXIM", NS_OX, 102) | |
96 | |
97 async def getTrustUI( # pylint: disable=invalid-name | |
98 self, | |
99 client: SatXMPPClient, | |
100 entity: jid.JID | |
101 ) -> xml_tools.XMLUI: | |
102 """ | |
103 @param client: The client. | |
104 @param entity: The entity whose device trust levels to manage. | |
105 @return: An XMLUI instance which opens a form to manage the trust level of all | |
106 devices belonging to the entity. | |
107 """ | |
108 | |
109 return await self.__xep_0373.getTrustUI(client, entity) | |
110 | |
111 @staticmethod | |
112 def __get_joined_muc_users( | |
113 client: SatXMPPClient, | |
114 xep_0045: XEP_0045, | |
115 room_jid: jid.JID | |
116 ) -> Set[jid.JID]: | |
117 """ | |
118 @param client: The client. | |
119 @param xep_0045: A MUC plugin instance. | |
120 @param room_jid: The room JID. | |
121 @return: A set containing the bare JIDs of the MUC participants. | |
122 @raise InternalError: if the MUC is not joined or the entity information of a | |
123 participant isn't available. | |
124 """ | |
125 | |
126 bare_jids: Set[jid.JID] = set() | |
127 | |
128 try: | |
129 room = cast(muc.Room, xep_0045.getRoom(client, room_jid)) | |
130 except exceptions.NotFound as e: | |
131 raise exceptions.InternalError( | |
132 "Participant list of unjoined MUC requested." | |
133 ) from e | |
134 | |
135 for user in cast(Dict[str, muc.User], room.roster).values(): | |
136 entity = cast(Optional[SatXMPPEntity], user.entity) | |
137 if entity is None: | |
138 raise exceptions.InternalError( | |
139 f"Participant list of MUC requested, but the entity information of" | |
140 f" the participant {user} is not available." | |
141 ) | |
142 | |
143 bare_jids.add(entity.jid.userhostJID()) | |
144 | |
145 return bare_jids | |
146 | |
147 async def __message_received_trigger( | |
148 self, | |
149 client: SatXMPPClient, | |
150 message_elt: domish.Element, | |
151 post_treat: defer.Deferred | |
152 ) -> bool: | |
153 """ | |
154 @param client: The client which received the message. | |
155 @param message_elt: The message element. Can be modified. | |
156 @param post_treat: A deferred which evaluates to a :class:`MessageData` once the | |
157 message has fully progressed through the message receiving flow. Can be used | |
158 to apply treatments to the fully processed message, like marking it as | |
159 encrypted. | |
160 @return: Whether to continue the message received flow. | |
161 """ | |
162 sender_jid = jid.JID(message_elt["from"]) | |
163 feedback_jid: jid.JID | |
164 | |
165 message_type = message_elt.getAttribute("type", "unknown") | |
166 is_muc_message = message_type == C.MESS_TYPE_GROUPCHAT | |
167 if is_muc_message: | |
168 if self.__xep_0045 is None: | |
169 log.warning( | |
170 "Ignoring MUC message since plugin XEP-0045 is not available." | |
171 ) | |
172 # Can't handle a MUC message without XEP-0045, let the flow continue | |
173 # normally | |
174 return True | |
175 | |
176 room_jid = feedback_jid = sender_jid.userhostJID() | |
177 | |
178 try: | |
179 room = cast(muc.Room, self.__xep_0045.getRoom(client, room_jid)) | |
180 except exceptions.NotFound: | |
181 log.warning( | |
182 f"Ignoring MUC message from a room that has not been joined:" | |
183 f" {room_jid}" | |
184 ) | |
185 # Whatever, let the flow continue | |
186 return True | |
187 | |
188 sender_user = cast(Optional[muc.User], room.getUser(sender_jid.resource)) | |
189 if sender_user is None: | |
190 log.warning( | |
191 f"Ignoring MUC message from room {room_jid} since the sender's user" | |
192 f" wasn't found {sender_jid.resource}" | |
193 ) | |
194 # Whatever, let the flow continue | |
195 return True | |
196 | |
197 sender_user_jid = cast(Optional[jid.JID], sender_user.entity) | |
198 if sender_user_jid is None: | |
199 log.warning( | |
200 f"Ignoring MUC message from room {room_jid} since the sender's bare" | |
201 f" JID couldn't be found from its user information: {sender_user}" | |
202 ) | |
203 # Whatever, let the flow continue | |
204 return True | |
205 | |
206 sender_jid = sender_user_jid | |
207 else: | |
208 # I'm not sure why this check is required, this code is copied from XEP-0384 | |
209 if sender_jid.userhostJID() == client.jid.userhostJID(): | |
210 # TODO: I've seen this cause an exception "builtins.KeyError: 'to'", seems | |
211 # like "to" isn't always set. | |
212 feedback_jid = jid.JID(message_elt["to"]) | |
213 else: | |
214 feedback_jid = sender_jid | |
215 | |
216 sender_bare_jid = sender_jid.userhost() | |
217 | |
218 openpgp_elt = cast(Optional[domish.Element], next( | |
219 message_elt.elements(NS_OX, "openpgp"), | |
220 None | |
221 )) | |
222 | |
223 if openpgp_elt is None: | |
224 # None of our business, let the flow continue | |
225 return True | |
226 | |
227 try: | |
228 payload_elt, timestamp = await self.__xep_0373.unpack_openpgp_element( | |
229 client, | |
230 openpgp_elt, | |
231 "signcrypt", | |
232 jid.JID(sender_bare_jid) | |
233 ) | |
234 except Exception as e: | |
235 # TODO: More specific exception handling | |
236 log.warning(_("Can't decrypt message: {reason}\n{xml}").format( | |
237 reason=e, | |
238 xml=message_elt.toXml() | |
239 )) | |
240 client.feedback( | |
241 feedback_jid, | |
242 D_( | |
243 f"An OXIM message from {sender_jid.full()} can't be decrypted:" | |
244 f" {e}" | |
245 ), | |
246 { C.MESS_EXTRA_INFO: C.EXTRA_INFO_DECR_ERR } | |
247 ) | |
248 # No point in further processing this message | |
249 return False | |
250 | |
251 message_elt.children.remove(openpgp_elt) | |
252 | |
253 log.debug(f"OXIM message of type {message_type} received from {sender_bare_jid}") | |
254 | |
255 # Remove all body elements from the original element, since those act as | |
256 # fallbacks in case the encryption protocol is not supported | |
257 for child in message_elt.elements(): | |
258 if child.name == "body": | |
259 message_elt.children.remove(child) | |
260 | |
261 # Move all extension elements from the payload to the stanza root | |
262 # TODO: There should probably be explicitly forbidden elements here too, just as | |
263 # for XEP-0420 | |
264 for child in list(payload_elt.elements()): | |
265 # Remove the child from the content element | |
266 payload_elt.children.remove(child) | |
267 | |
268 # Add the child to the stanza | |
269 message_elt.addChild(child) | |
270 | |
271 # Mark the message as trusted or untrusted. Undecided counts as untrusted here. | |
272 trust_level = TrustLevel.UNDECIDED # TODO: Load the actual trust level | |
273 if trust_level is TrustLevel.TRUSTED: | |
274 post_treat.addCallback(client.encryption.markAsTrusted) | |
275 else: | |
276 post_treat.addCallback(client.encryption.markAsUntrusted) | |
277 | |
278 # Mark the message as originally encrypted | |
279 post_treat.addCallback( | |
280 client.encryption.markAsEncrypted, | |
281 namespace=NS_OX | |
282 ) | |
283 | |
284 # Message processed successfully, continue with the flow | |
285 return True | |
286 | |
287 async def __send_trigger(self, client: SatXMPPClient, stanza: domish.Element) -> bool: | |
288 """ | |
289 @param client: The client sending this message. | |
290 @param stanza: The stanza that is about to be sent. Can be modified. | |
291 @return: Whether the send message flow should continue or not. | |
292 """ | |
293 # OXIM only handles message stanzas | |
294 if stanza.name != "message": | |
295 return True | |
296 | |
297 # Get the intended recipient | |
298 recipient = stanza.getAttribute("to", None) | |
299 if recipient is None: | |
300 raise exceptions.InternalError( | |
301 f"Message without recipient encountered. Blocking further processing to" | |
302 f" avoid leaking plaintext data: {stanza.toXml()}" | |
303 ) | |
304 | |
305 # Parse the JID | |
306 recipient_bare_jid = jid.JID(recipient).userhostJID() | |
307 | |
308 # Check whether encryption with OXIM is requested | |
309 encryption = client.encryption.getSession(recipient_bare_jid) | |
310 | |
311 if encryption is None: | |
312 # Encryption is not requested for this recipient | |
313 return True | |
314 | |
315 if encryption["plugin"].namespace != NS_OX: | |
316 # Encryption is requested for this recipient, but not with OXIM | |
317 return True | |
318 | |
319 # All pre-checks done, we can start encrypting! | |
320 await self.__encrypt( | |
321 client, | |
322 stanza, | |
323 recipient_bare_jid, | |
324 stanza.getAttribute("type", "unkown") == C.MESS_TYPE_GROUPCHAT | |
325 ) | |
326 | |
327 # Add a store hint if this is a message stanza | |
328 self.__xep_0334.addHintElements(stanza, [ "store" ]) | |
329 | |
330 # Let the flow continue. | |
331 return True | |
332 | |
333 async def __encrypt( | |
334 self, | |
335 client: SatXMPPClient, | |
336 stanza: domish.Element, | |
337 recipient_jid: jid.JID, | |
338 is_muc_message: bool | |
339 ) -> None: | |
340 """ | |
341 @param client: The client. | |
342 @param stanza: The stanza, which is modified by this call. | |
343 @param recipient_jid: The JID of the recipient. Can be a bare (aka "userhost") JID | |
344 but doesn't have to. | |
345 @param is_muc_message: Whether the stanza is a message stanza to a MUC room. | |
346 | |
347 @warning: The calling code MUST take care of adding the store message processing | |
348 hint to the stanza if applicable! This can be done before or after this call, | |
349 the order doesn't matter. | |
350 """ | |
351 | |
352 recipient_bare_jids: Set[jid.JID] | |
353 feedback_jid: jid.JID | |
354 | |
355 if is_muc_message: | |
356 if self.__xep_0045 is None: | |
357 raise exceptions.InternalError( | |
358 "Encryption of MUC message requested, but plugin XEP-0045 is not" | |
359 " available." | |
360 ) | |
361 | |
362 room_jid = feedback_jid = recipient_jid.userhostJID() | |
363 | |
364 recipient_bare_jids = self.__get_joined_muc_users( | |
365 client, | |
366 self.__xep_0045, | |
367 room_jid | |
368 ) | |
369 else: | |
370 recipient_bare_jids = { recipient_jid.userhostJID() } | |
371 feedback_jid = recipient_jid.userhostJID() | |
372 | |
373 log.debug( | |
374 f"Intercepting message that is to be encrypted by {NS_OX} for" | |
375 f" {recipient_bare_jids}" | |
376 ) | |
377 | |
378 signcrypt_elt, payload_elt = \ | |
379 self.__xep_0373.build_signcrypt_element(recipient_bare_jids) | |
380 | |
381 # Move elements from the stanza to the content element. | |
382 # TODO: There should probably be explicitly forbidden elements here too, just as | |
383 # for XEP-0420 | |
384 for child in list(stanza.elements()): | |
385 # Remove the child from the stanza | |
386 stanza.children.remove(child) | |
387 | |
388 # A namespace of ``None`` can be used on domish elements to inherit the | |
389 # namespace from the parent. When moving elements from the stanza root to | |
390 # the content element, however, we don't want elements to inherit the | |
391 # namespace of the content element. Thus, check for elements with ``None`` | |
392 # for their namespace and set the namespace to jabber:client, which is the | |
393 # namespace of the parent element. | |
394 if child.uri is None: | |
395 child.uri = C.NS_CLIENT | |
396 child.defaultUri = C.NS_CLIENT | |
397 | |
398 # Add the child with corrected namespaces to the content element | |
399 payload_elt.addChild(child) | |
400 | |
401 try: | |
402 openpgp_elt = await self.__xep_0373.build_openpgp_element( | |
403 client, | |
404 signcrypt_elt, | |
405 recipient_bare_jids | |
406 ) | |
407 except Exception as e: | |
408 msg = _( | |
409 # pylint: disable=consider-using-f-string | |
410 "Can't encrypt message for {entities}: {reason}".format( | |
411 entities=', '.join(jid.userhost() for jid in recipient_bare_jids), | |
412 reason=e | |
413 ) | |
414 ) | |
415 log.warning(msg) | |
416 client.feedback(feedback_jid, msg, { | |
417 C.MESS_EXTRA_INFO: C.EXTRA_INFO_ENCR_ERR | |
418 }) | |
419 raise e | |
420 | |
421 stanza.addChild(openpgp_elt) |