comparison libervia/backend/memory/encryption.py @ 4071:4b842c1fb686

refactoring: renamed `sat` package to `libervia.backend`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 11:49:51 +0200
parents sat/memory/encryption.py@c23cad65ae99
children 0d7bb4df2343
comparison
equal deleted inserted replaced
4070:d10748475025 4071:4b842c1fb686
1 #!/usr/bin/env python3
2
3
4 # SAT: a jabber client
5 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 import copy
21 from functools import partial
22 from typing import Optional
23 from twisted.words.protocols.jabber import jid
24 from twisted.internet import defer
25 from twisted.python import failure
26 from libervia.backend.core.core_types import EncryptionPlugin, EncryptionSession, MessageData
27 from libervia.backend.core.i18n import D_, _
28 from libervia.backend.core.constants import Const as C
29 from libervia.backend.core import exceptions
30 from libervia.backend.core.log import getLogger
31 from libervia.backend.tools.common import data_format
32 from libervia.backend.tools import utils
33 from libervia.backend.memory import persistent
34
35
36 log = getLogger(__name__)
37
38
39 class EncryptionHandler:
40 """Class to handle encryption sessions for a client"""
41 plugins = [] # plugin able to encrypt messages
42
43 def __init__(self, client):
44 self.client = client
45 self._sessions = {} # bare_jid ==> encryption_data
46 self._stored_session = persistent.PersistentDict(
47 "core:encryption", profile=client.profile)
48
49 @property
50 def host(self):
51 return self.client.host_app
52
53 async def load_sessions(self):
54 """Load persistent sessions"""
55 await self._stored_session.load()
56 start_d_list = []
57 for entity_jid_s, namespace in self._stored_session.items():
58 entity = jid.JID(entity_jid_s)
59 start_d_list.append(defer.ensureDeferred(self.start(entity, namespace)))
60
61 if start_d_list:
62 result = await defer.DeferredList(start_d_list)
63 for idx, (success, err) in enumerate(result):
64 if not success:
65 entity_jid_s, namespace = list(self._stored_session.items())[idx]
66 log.warning(_(
67 "Could not restart {namespace!r} encryption with {entity}: {err}"
68 ).format(namespace=namespace, entity=entity_jid_s, err=err))
69 log.info(_("encryption sessions restored"))
70
71 @classmethod
72 def register_plugin(cls, plg_instance, name, namespace, priority=0, directed=False):
73 """Register a plugin handling an encryption algorithm
74
75 @param plg_instance(object): instance of the plugin
76 it must have the following methods:
77 - get_trust_ui(entity): return a XMLUI for trust management
78 entity(jid.JID): entity to manage
79 The returned XMLUI must be a form
80 if may have the following methods:
81 - start_encryption(entity): start encrypted session
82 entity(jid.JID): entity to start encrypted session with
83 - stop_encryption(entity): start encrypted session
84 entity(jid.JID): entity to stop encrypted session with
85 if they don't exists, those 2 methods will be ignored.
86
87 @param name(unicode): human readable name of the encryption algorithm
88 @param namespace(unicode): namespace of the encryption algorithm
89 @param priority(int): priority of this plugin to encrypt an message when not
90 selected manually
91 @param directed(bool): True if this plugin is directed (if it works with one
92 device only at a time)
93 """
94 existing_ns = set()
95 existing_names = set()
96 for p in cls.plugins:
97 existing_ns.add(p.namespace.lower())
98 existing_names.add(p.name.lower())
99 if namespace.lower() in existing_ns:
100 raise exceptions.ConflictError("A plugin with this namespace already exists!")
101 if name.lower() in existing_names:
102 raise exceptions.ConflictError("A plugin with this name already exists!")
103 plugin = EncryptionPlugin(
104 instance=plg_instance,
105 name=name,
106 namespace=namespace,
107 priority=priority,
108 directed=directed)
109 cls.plugins.append(plugin)
110 cls.plugins.sort(key=lambda p: p.priority)
111 log.info(_("Encryption plugin registered: {name}").format(name=name))
112
113 @classmethod
114 def getPlugins(cls):
115 return cls.plugins
116
117 @classmethod
118 def get_plugin(cls, namespace):
119 try:
120 return next(p for p in cls.plugins if p.namespace == namespace)
121 except StopIteration:
122 raise exceptions.NotFound(_(
123 "Can't find requested encryption plugin: {namespace}").format(
124 namespace=namespace))
125
126 @classmethod
127 def get_namespaces(cls):
128 """Get available plugin namespaces"""
129 return {p.namespace for p in cls.getPlugins()}
130
131 @classmethod
132 def get_ns_from_name(cls, name):
133 """Retrieve plugin namespace from its name
134
135 @param name(unicode): name of the plugin (case insensitive)
136 @return (unicode): namespace of the plugin
137 @raise exceptions.NotFound: there is not encryption plugin of this name
138 """
139 for p in cls.plugins:
140 if p.name.lower() == name.lower():
141 return p.namespace
142 raise exceptions.NotFound(_(
143 "Can't find a plugin with the name \"{name}\".".format(
144 name=name)))
145
146 def get_bridge_data(self, session):
147 """Retrieve session data serialized for bridge.
148
149 @param session(dict): encryption session
150 @return (unicode): serialized data for bridge
151 """
152 if session is None:
153 return ''
154 plugin = session['plugin']
155 bridge_data = {'name': plugin.name,
156 'namespace': plugin.namespace}
157 if 'directed_devices' in session:
158 bridge_data['directed_devices'] = session['directed_devices']
159
160 return data_format.serialise(bridge_data)
161
162 async def _start_encryption(self, plugin, entity):
163 """Start encryption with a plugin
164
165 This method must be called just before adding a plugin session.
166 StartEncryptionn method of plugin will be called if it exists.
167 """
168 if not plugin.directed:
169 await self._stored_session.aset(entity.userhost(), plugin.namespace)
170 try:
171 start_encryption = plugin.instance.start_encryption
172 except AttributeError:
173 log.debug(f"No start_encryption method found for {plugin.namespace}")
174 else:
175 # we copy entity to avoid having the resource changed by stop_encryption
176 await utils.as_deferred(start_encryption, self.client, copy.copy(entity))
177
178 async def _stop_encryption(self, plugin, entity):
179 """Stop encryption with a plugin
180
181 This method must be called just before removing a plugin session.
182 StopEncryptionn method of plugin will be called if it exists.
183 """
184 try:
185 await self._stored_session.adel(entity.userhost())
186 except KeyError:
187 pass
188 try:
189 stop_encryption = plugin.instance.stop_encryption
190 except AttributeError:
191 log.debug(f"No stop_encryption method found for {plugin.namespace}")
192 else:
193 # we copy entity to avoid having the resource changed by stop_encryption
194 return utils.as_deferred(stop_encryption, self.client, copy.copy(entity))
195
196 async def start(self, entity, namespace=None, replace=False):
197 """Start an encryption session with an entity
198
199 @param entity(jid.JID): entity to start an encryption session with
200 must be bare jid is the algorithm encrypt for all devices
201 @param namespace(unicode, None): namespace of the encryption algorithm
202 to use.
203 None to select automatically an algorithm
204 @param replace(bool): if True and an encrypted session already exists,
205 it will be replaced by the new one
206 """
207 if not self.plugins:
208 raise exceptions.NotFound(_("No encryption plugin is registered, "
209 "an encryption session can't be started"))
210
211 if namespace is None:
212 plugin = self.plugins[0]
213 else:
214 plugin = self.get_plugin(namespace)
215
216 bare_jid = entity.userhostJID()
217 if bare_jid in self._sessions:
218 # we have already an encryption session with this contact
219 former_plugin = self._sessions[bare_jid]["plugin"]
220 if former_plugin.namespace == namespace:
221 log.info(_("Session with {bare_jid} is already encrypted with {name}. "
222 "Nothing to do.").format(
223 bare_jid=bare_jid, name=former_plugin.name))
224 return
225
226 if replace:
227 # there is a conflict, but replacement is requested
228 # so we stop previous encryption to use new one
229 del self._sessions[bare_jid]
230 await self._stop_encryption(former_plugin, entity)
231 else:
232 msg = (_("Session with {bare_jid} is already encrypted with {name}. "
233 "Please stop encryption session before changing algorithm.")
234 .format(bare_jid=bare_jid, name=plugin.name))
235 log.warning(msg)
236 raise exceptions.ConflictError(msg)
237
238 data = {"plugin": plugin}
239 if plugin.directed:
240 if not entity.resource:
241 entity.resource = self.host.memory.main_resource_get(self.client, entity)
242 if not entity.resource:
243 raise exceptions.NotFound(
244 _("No resource found for {destinee}, can't encrypt with {name}")
245 .format(destinee=entity.full(), name=plugin.name))
246 log.info(_("No resource specified to encrypt with {name}, using "
247 "{destinee}.").format(destinee=entity.full(),
248 name=plugin.name))
249 # indicate that we encrypt only for some devices
250 directed_devices = data['directed_devices'] = [entity.resource]
251 elif entity.resource:
252 raise ValueError(_("{name} encryption must be used with bare jids."))
253
254 await self._start_encryption(plugin, entity)
255 self._sessions[entity.userhostJID()] = data
256 log.info(_("Encryption session has been set for {entity_jid} with "
257 "{encryption_name}").format(
258 entity_jid=entity.full(), encryption_name=plugin.name))
259 self.host.bridge.message_encryption_started(
260 entity.full(),
261 self.get_bridge_data(data),
262 self.client.profile)
263 msg = D_("Encryption session started: your messages with {destinee} are "
264 "now end to end encrypted using {name} algorithm.").format(
265 destinee=entity.full(), name=plugin.name)
266 directed_devices = data.get('directed_devices')
267 if directed_devices:
268 msg += "\n" + D_("Message are encrypted only for {nb_devices} device(s): "
269 "{devices_list}.").format(
270 nb_devices=len(directed_devices),
271 devices_list = ', '.join(directed_devices))
272
273 self.client.feedback(bare_jid, msg)
274
275 async def stop(self, entity, namespace=None):
276 """Stop an encryption session with an entity
277
278 @param entity(jid.JID): entity with who the encryption session must be stopped
279 must be bare jid if the algorithm encrypt for all devices
280 @param namespace(unicode): namespace of the session to stop
281 when specified, used to check that we stop the right encryption session
282 """
283 session = self.getSession(entity.userhostJID())
284 if not session:
285 raise failure.Failure(
286 exceptions.NotFound(_("There is no encryption session with this "
287 "entity.")))
288 plugin = session['plugin']
289 if namespace is not None and plugin.namespace != namespace:
290 raise exceptions.InternalError(_(
291 "The encryption session is not run with the expected plugin: encrypted "
292 "with {current_name} and was expecting {expected_name}").format(
293 current_name=session['plugin'].namespace,
294 expected_name=namespace))
295 if entity.resource:
296 try:
297 directed_devices = session['directed_devices']
298 except KeyError:
299 raise exceptions.NotFound(_(
300 "There is a session for the whole entity (i.e. all devices of the "
301 "entity), not a directed one. Please use bare jid if you want to "
302 "stop the whole encryption with this entity."))
303
304 try:
305 directed_devices.remove(entity.resource)
306 except ValueError:
307 raise exceptions.NotFound(_("There is no directed session with this "
308 "entity."))
309 else:
310 if not directed_devices:
311 # if we have no more directed device sessions,
312 # we stop the whole session
313 # see comment below for deleting session before stopping encryption
314 del self._sessions[entity.userhostJID()]
315 await self._stop_encryption(plugin, entity)
316 else:
317 # plugin's stop_encryption may call stop again (that's the case with OTR)
318 # so we need to remove plugin from session before calling self._stop_encryption
319 del self._sessions[entity.userhostJID()]
320 await self._stop_encryption(plugin, entity)
321
322 log.info(_("encryption session stopped with entity {entity}").format(
323 entity=entity.full()))
324 self.host.bridge.message_encryption_stopped(
325 entity.full(),
326 {'name': plugin.name,
327 'namespace': plugin.namespace,
328 },
329 self.client.profile)
330 msg = D_("Encryption session finished: your messages with {destinee} are "
331 "NOT end to end encrypted anymore.\nYour server administrators or "
332 "{destinee} server administrators will be able to read them.").format(
333 destinee=entity.full())
334
335 self.client.feedback(entity, msg)
336
337 def getSession(self, entity: jid.JID) -> Optional[EncryptionSession]:
338 """Get encryption session for this contact
339
340 @param entity(jid.JID): get the session for this entity
341 must be a bare jid
342 @return (dict, None): encryption session data
343 None if there is not encryption for this session with this jid
344 """
345 if entity.resource:
346 raise ValueError("Full jid given when expecting bare jid")
347 return self._sessions.get(entity)
348
349 def get_namespace(self, entity: jid.JID) -> Optional[str]:
350 """Helper method to get the current encryption namespace used
351
352 @param entity: get the namespace for this entity must be a bare jid
353 @return: the algorithm namespace currently used in this session, or None if no
354 e2ee is currently used.
355 """
356 session = self.getSession(entity)
357 if session is None:
358 return None
359 return session["plugin"].namespace
360
361 def get_trust_ui(self, entity_jid, namespace=None):
362 """Retrieve encryption UI
363
364 @param entity_jid(jid.JID): get the UI for this entity
365 must be a bare jid
366 @param namespace(unicode): namespace of the algorithm to manage
367 if None use current algorithm
368 @return D(xmlui): XMLUI for trust management
369 the xmlui is a form
370 None if there is not encryption for this session with this jid
371 @raise exceptions.NotFound: no algorithm/plugin found
372 @raise NotImplementedError: plugin doesn't handle UI management
373 """
374 if namespace is None:
375 session = self.getSession(entity_jid)
376 if not session:
377 raise exceptions.NotFound(
378 "No encryption session currently active for {entity_jid}"
379 .format(entity_jid=entity_jid.full()))
380 plugin = session['plugin']
381 else:
382 plugin = self.get_plugin(namespace)
383 try:
384 get_trust_ui = plugin.instance.get_trust_ui
385 except AttributeError:
386 raise NotImplementedError(
387 "Encryption plugin doesn't handle trust management UI")
388 else:
389 return utils.as_deferred(get_trust_ui, self.client, entity_jid)
390
391 ## Menus ##
392
393 @classmethod
394 def _import_menus(cls, host):
395 host.import_menu(
396 (D_("Encryption"), D_("unencrypted (plain text)")),
397 partial(cls._on_menu_unencrypted, host=host),
398 security_limit=0,
399 help_string=D_("End encrypted session"),
400 type_=C.MENU_SINGLE,
401 )
402 for plg in cls.getPlugins():
403 host.import_menu(
404 (D_("Encryption"), plg.name),
405 partial(cls._on_menu_name, host=host, plg=plg),
406 security_limit=0,
407 help_string=D_("Start {name} session").format(name=plg.name),
408 type_=C.MENU_SINGLE,
409 )
410 host.import_menu(
411 (D_("Encryption"), D_("⛨ {name} trust").format(name=plg.name)),
412 partial(cls._on_menu_trust, host=host, plg=plg),
413 security_limit=0,
414 help_string=D_("Manage {name} trust").format(name=plg.name),
415 type_=C.MENU_SINGLE,
416 )
417
418 @classmethod
419 def _on_menu_unencrypted(cls, data, host, profile):
420 client = host.get_client(profile)
421 peer_jid = jid.JID(data['jid']).userhostJID()
422 d = defer.ensureDeferred(client.encryption.stop(peer_jid))
423 d.addCallback(lambda __: {})
424 return d
425
426 @classmethod
427 def _on_menu_name(cls, data, host, plg, profile):
428 client = host.get_client(profile)
429 peer_jid = jid.JID(data['jid'])
430 if not plg.directed:
431 peer_jid = peer_jid.userhostJID()
432 d = defer.ensureDeferred(
433 client.encryption.start(peer_jid, plg.namespace, replace=True))
434 d.addCallback(lambda __: {})
435 return d
436
437 @classmethod
438 @defer.inlineCallbacks
439 def _on_menu_trust(cls, data, host, plg, profile):
440 client = host.get_client(profile)
441 peer_jid = jid.JID(data['jid']).userhostJID()
442 ui = yield client.encryption.get_trust_ui(peer_jid, plg.namespace)
443 defer.returnValue({'xmlui': ui.toXml()})
444
445 ## Triggers ##
446
447 def set_encryption_flag(self, mess_data):
448 """Set "encryption" key in mess_data if session with destinee is encrypted"""
449 to_jid = mess_data['to']
450 encryption = self._sessions.get(to_jid.userhostJID())
451 if encryption is not None:
452 plugin = encryption['plugin']
453 if mess_data["type"] == "groupchat" and plugin.directed:
454 raise exceptions.InternalError(
455 f"encryption flag must not be set for groupchat if encryption algorithm "
456 f"({encryption['plugin'].name}) is directed!")
457 mess_data[C.MESS_KEY_ENCRYPTION] = encryption
458 self.mark_as_encrypted(mess_data, plugin.namespace)
459
460 ## Misc ##
461
462 def mark_as_encrypted(self, mess_data, namespace):
463 """Helper method to mark a message as having been e2e encrypted.
464
465 This should be used in the post_treat workflow of message_received trigger of
466 the plugin
467 @param mess_data(dict): message data as used in post treat workflow
468 @param namespace(str): namespace of the algorithm used for encrypting the message
469 """
470 mess_data['extra'][C.MESS_KEY_ENCRYPTED] = True
471 from_bare_jid = mess_data['from'].userhostJID()
472 if from_bare_jid != self.client.jid.userhostJID():
473 session = self.getSession(from_bare_jid)
474 if session is None:
475 # if we are currently unencrypted, we start a session automatically
476 # to avoid sending unencrypted messages in an encrypted context
477 log.info(_(
478 "Starting e2e session with {peer_jid} as we receive encrypted "
479 "messages")
480 .format(peer_jid=from_bare_jid)
481 )
482 defer.ensureDeferred(self.start(from_bare_jid, namespace))
483
484 return mess_data
485
486 def is_encryption_requested(
487 self,
488 mess_data: MessageData,
489 namespace: Optional[str] = None
490 ) -> bool:
491 """Helper method to check if encryption is requested in an outgoind message
492
493 @param mess_data: message data for outgoing message
494 @param namespace: if set, check if encryption is requested for the algorithm
495 specified
496 @return: True if the encryption flag is present
497 """
498 encryption = mess_data.get(C.MESS_KEY_ENCRYPTION)
499 if encryption is None:
500 return False
501 # we get plugin even if namespace is None to be sure that the key exists
502 plugin = encryption['plugin']
503 if namespace is None:
504 return True
505 return plugin.namespace == namespace
506
507 def isEncrypted(self, mess_data):
508 """Helper method to check if a message has the e2e encrypted flag
509
510 @param mess_data(dict): message data
511 @return (bool): True if the encrypted flag is present
512 """
513 return mess_data['extra'].get(C.MESS_KEY_ENCRYPTED, False)
514
515
516 def mark_as_trusted(self, mess_data):
517 """Helper methor to mark a message as sent from a trusted entity.
518
519 This should be used in the post_treat workflow of message_received trigger of
520 the plugin
521 @param mess_data(dict): message data as used in post treat workflow
522 """
523 mess_data[C.MESS_KEY_TRUSTED] = True
524 return mess_data
525
526 def mark_as_untrusted(self, mess_data):
527 """Helper methor to mark a message as sent from an untrusted entity.
528
529 This should be used in the post_treat workflow of message_received trigger of
530 the plugin
531 @param mess_data(dict): message data as used in post treat workflow
532 """
533 mess_data['trusted'] = False
534 return mess_data