Mercurial > libervia-backend
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 |