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)