Mercurial > libervia-backend
comparison libervia/backend/plugins/plugin_exp_gre.py @ 4344:95f8309f86cf
plugin GRE: implements Gateway Relayed Encryption:
rel 455
author | Goffi <goffi@goffi.org> |
---|---|
date | Mon, 13 Jan 2025 01:23:22 +0100 |
parents | |
children |
comparison
equal
deleted
inserted
replaced
4343:627f872bc16e | 4344:95f8309f86cf |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # Libervia plugin | |
4 # Copyright (C) 2009-2025 Jérôme Poisson (goffi@goffi.org) | |
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 abc import ABC, abstractmethod | |
20 from typing import Final, TYPE_CHECKING, Self, Type, cast | |
21 | |
22 from twisted.internet import defer | |
23 from twisted.words.protocols.jabber import jid, error as jabber_error | |
24 from twisted.words.protocols.jabber import xmlstream | |
25 from twisted.words.protocols.jabber.xmlstream import XMPPHandler | |
26 from twisted.words.xish import domish | |
27 from wokkel import data_form, disco, iwokkel | |
28 from zope.interface import implementer | |
29 | |
30 from libervia.backend.core import exceptions | |
31 from libervia.backend.core.constants import Const as C | |
32 from libervia.backend.core.core_types import SatXMPPEntity | |
33 from libervia.backend.core.i18n import _ | |
34 from libervia.backend.core.log import getLogger | |
35 from libervia.backend.plugins.plugin_xep_0106 import XEP_0106 | |
36 from libervia.backend.tools import xml_tools | |
37 | |
38 if TYPE_CHECKING: | |
39 from libervia.backend.core.main import LiberviaBackend | |
40 | |
41 log = getLogger(__name__) | |
42 | |
43 | |
44 PLUGIN_INFO = { | |
45 C.PI_NAME: "Gateway Relayer Encryption", | |
46 C.PI_IMPORT_NAME: "GRE", | |
47 C.PI_TYPE: "XEP", | |
48 C.PI_MODES: C.PLUG_MODE_BOTH, | |
49 C.PI_PROTOCOLS: [], | |
50 C.PI_DEPENDENCIES: ["XEP-0106"], | |
51 C.PI_RECOMMENDATIONS: [], | |
52 C.PI_MAIN: "GRE", | |
53 C.PI_HANDLER: "yes", | |
54 C.PI_DESCRIPTION: _( | |
55 "Handle formatting and encryption to support end-to-end encryption with gateways." | |
56 ), | |
57 } | |
58 | |
59 NS_GRE_PREFIX: Final = "urn:xmpp:gre:" | |
60 NS_GRE: Final = f"{NS_GRE_PREFIX}0" | |
61 NS_GRE_FORMATTER_PREFIX: Final = f"{NS_GRE_PREFIX}formatter:" | |
62 NS_GRE_ENCRYPTER_PREFIX: Final = f"{NS_GRE_PREFIX}encrypter:" | |
63 NS_GRE_DATA: Final = f"{NS_GRE_PREFIX}data" | |
64 | |
65 IQ_DATA_REQUEST = C.IQ_GET + '/data[@xmlns="' + NS_GRE + '"]' | |
66 | |
67 | |
68 class Formatter(ABC): | |
69 | |
70 formatters_classes: dict[str, Type[Self]] = {} | |
71 name: str = "" | |
72 namespace: str = "" | |
73 _instance: Self | None = None | |
74 | |
75 def __init_subclass__(cls, **kwargs) -> None: | |
76 """ | |
77 Registers the subclass in the formatters dictionary. | |
78 | |
79 @param kwargs: Additional keyword arguments. | |
80 """ | |
81 assert cls.name and cls.namespace, "name and namespace must be set" | |
82 super().__init_subclass__(**kwargs) | |
83 cls.formatters_classes[cls.namespace] = cls | |
84 | |
85 def __init__(self, host: "LiberviaBackend") -> None: | |
86 assert self.__class__._instance is None, "Formatter class must be singleton." | |
87 self.__class__._instance = self | |
88 self.host = host | |
89 | |
90 @classmethod | |
91 def get_instance(cls) -> Self: | |
92 if cls._instance is None: | |
93 raise exceptions.InternalError("Formatter instance should be set.") | |
94 return cls._instance | |
95 | |
96 @abstractmethod | |
97 async def format( | |
98 self, | |
99 client: SatXMPPEntity, | |
100 recipient_id: str, | |
101 message_elt: domish.Element, | |
102 encryption_data_form: data_form.Form, | |
103 ) -> bytes: | |
104 raise NotImplementedError | |
105 | |
106 | |
107 class Encrypter(ABC): | |
108 | |
109 encrypters_classes: dict[str, Type[Self]] = {} | |
110 name: str = "" | |
111 namespace: str = "" | |
112 _instance: Self | None = None | |
113 | |
114 def __init_subclass__(cls, **kwargs) -> None: | |
115 """ | |
116 Registers the subclass in the encrypters dictionary. | |
117 | |
118 @param kwargs: Additional keyword arguments. | |
119 """ | |
120 assert cls.name and cls.namespace, "name and namespace must be set" | |
121 super().__init_subclass__(**kwargs) | |
122 cls.encrypters_classes[cls.namespace] = cls | |
123 | |
124 def __init__(self, host: "LiberviaBackend") -> None: | |
125 assert self.__class__._instance is None, "Encrypter class must be singleton." | |
126 self.__class__._instance = self | |
127 self.host = host | |
128 | |
129 @classmethod | |
130 def get_instance(cls) -> Self: | |
131 if cls._instance is None: | |
132 raise exceptions.InternalError("Encrypter instance should be set.") | |
133 return cls._instance | |
134 | |
135 @abstractmethod | |
136 async def encrypt( | |
137 self, | |
138 client: SatXMPPEntity, | |
139 recipient_id: str, | |
140 message_elt: domish.Element, | |
141 formatted_payload: bytes, | |
142 encryption_data_form: data_form.Form, | |
143 ) -> str: | |
144 raise NotImplementedError | |
145 | |
146 | |
147 class GetDataHandler(ABC): | |
148 gre_formatters: list[str] = [] | |
149 gre_encrypters: list[str] = [] | |
150 | |
151 def __init_subclass__(cls, **kwargs): | |
152 super().__init_subclass__(**kwargs) | |
153 if not cls.gre_formatters or not cls.gre_encrypters: | |
154 raise TypeError( | |
155 f'{cls.__name__} must define "gre_formatters" and "gre_encrypters"' | |
156 ) | |
157 | |
158 @abstractmethod | |
159 async def on_relayed_encryption_data( | |
160 self, client: SatXMPPEntity, iq_elt: domish.Element, form: data_form.Form | |
161 ) -> None: | |
162 raise NotImplementedError | |
163 | |
164 | |
165 class GRE: | |
166 namespace = NS_GRE | |
167 | |
168 def __init__(self, host: "LiberviaBackend") -> None: | |
169 log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization") | |
170 self.host = host | |
171 self._e = cast(XEP_0106, host.plugins["XEP-0106"]) | |
172 self._data_handlers: dict[SatXMPPEntity, GetDataHandler] = {} | |
173 host.register_namespace("gre", NS_GRE) | |
174 self.host.register_encryption_plugin(self, "Relayed", NS_GRE) | |
175 host.trigger.add("send", self.send_trigger, priority=0) | |
176 | |
177 def register_get_data_handler( | |
178 self, client: SatXMPPEntity, handler: GetDataHandler | |
179 ) -> None: | |
180 if client in self._data_handlers: | |
181 raise exceptions.InternalError( | |
182 '"register_get_data_handler" should not be called twice for the same ' | |
183 "handler." | |
184 ) | |
185 self._data_handlers[client] = handler | |
186 | |
187 def _on_component_data_request( | |
188 self, iq_elt: domish.Element, client: SatXMPPEntity | |
189 ) -> None: | |
190 iq_elt.handled = True | |
191 defer.ensureDeferred(self.on_component_data_request(client, iq_elt)) | |
192 | |
193 async def on_component_data_request( | |
194 self, client: SatXMPPEntity, iq_elt: domish.Element | |
195 ) -> None: | |
196 form = data_form.Form( | |
197 "result", "Relayed Data Encryption", formNamespace=NS_GRE_DATA | |
198 ) | |
199 try: | |
200 handler = self._data_handlers[client] | |
201 except KeyError: | |
202 pass | |
203 else: | |
204 await handler.on_relayed_encryption_data(client, iq_elt, form) | |
205 iq_result_elt = xmlstream.toResponse(iq_elt, "result") | |
206 data_elt = iq_result_elt.addElement((NS_GRE, "data")) | |
207 data_elt.addChild(form.toElement()) | |
208 client.send(iq_result_elt) | |
209 | |
210 async def get_formatter_and_encrypter( | |
211 self, client: SatXMPPEntity, gateway_jid: jid.JID | |
212 ) -> tuple[Formatter, Encrypter]: | |
213 """Retrieve Formatter and Encrypter instances for given gateway. | |
214 | |
215 @param client: client session. | |
216 @param gateway_jid: bare jid of the gateway. | |
217 @return: Formatter and Encrypter instances. | |
218 @raise exceptions.FeatureNotFound: No relevant Formatter or Encrypter could be | |
219 found. | |
220 """ | |
221 disco_infos = await self.host.memory.disco.get_infos(client, gateway_jid) | |
222 try: | |
223 formatter_ns = next( | |
224 f for f in disco_infos.features if f.startswith(NS_GRE_FORMATTER_PREFIX) | |
225 ) | |
226 encrypter_ns = next( | |
227 f for f in disco_infos.features if f.startswith(NS_GRE_ENCRYPTER_PREFIX) | |
228 ) | |
229 formatter_cls = Formatter.formatters_classes[formatter_ns] | |
230 encrypter_cls = Encrypter.encrypters_classes[encrypter_ns] | |
231 except StopIteration as e: | |
232 raise exceptions.FeatureNotFound("No relayed encryption found.") from e | |
233 except KeyError as e: | |
234 raise exceptions.FeatureNotFound( | |
235 "No compatible relayed encryption found." | |
236 ) from e | |
237 | |
238 return formatter_cls.get_instance(), encrypter_cls.get_instance() | |
239 | |
240 def get_encrypted_payload( | |
241 self, | |
242 message_elt: domish.Element, | |
243 ) -> str | None: | |
244 """Return encrypted payload if any. | |
245 | |
246 @param message_elt: The message element. | |
247 @return: Encrypted payload if any, None otherwise. | |
248 """ | |
249 encrypted_elt = next(message_elt.elements(NS_GRE, "encrypted"), None) | |
250 if encrypted_elt is None: | |
251 return None | |
252 return str(encrypted_elt) | |
253 | |
254 async def send_trigger( | |
255 self, client: SatXMPPEntity, stanza_elt: domish.Element | |
256 ) -> bool: | |
257 """ | |
258 @param client: Profile session. | |
259 @param stanza: The stanza that is about to be sent. | |
260 @return: Whether the send message flow should continue or not. | |
261 """ | |
262 if stanza_elt.name != "message": | |
263 return True | |
264 | |
265 try: | |
266 recipient = jid.JID(stanza_elt["to"]) | |
267 except (jabber_error.StanzaError, RuntimeError, jid.InvalidFormat) as e: | |
268 raise exceptions.InternalError( | |
269 "Message without recipient encountered. Blocking further processing to" | |
270 f" avoid leaking plaintext data: {stanza_elt.toXml()}" | |
271 ) from e | |
272 | |
273 recipient_bare_jid = recipient.userhostJID() | |
274 | |
275 encryption_session = client.encryption.getSession(recipient_bare_jid) | |
276 if encryption_session is None: | |
277 return True | |
278 if encryption_session["plugin"].namespace != NS_GRE: | |
279 return True | |
280 | |
281 # We are in a relayed encryption session. | |
282 | |
283 encryption_data_form = await self.get_data(client, recipient_bare_jid) | |
284 | |
285 formatter, encrypter = await self.get_formatter_and_encrypter( | |
286 client, recipient_bare_jid | |
287 ) | |
288 | |
289 try: | |
290 recipient_id = self._e.unescape(recipient.user) | |
291 except ValueError as e: | |
292 raise exceptions.DataError('"to" attribute is not in expected fomat') from e | |
293 | |
294 formatted_payload = await formatter.format( | |
295 client, recipient_id, stanza_elt, encryption_data_form | |
296 ) | |
297 encrypted_payload = await encrypter.encrypt( | |
298 client, recipient_id, stanza_elt, formatted_payload, encryption_data_form | |
299 ) | |
300 | |
301 for body_elt in list(stanza_elt.elements(None, "body")): | |
302 stanza_elt.children.remove(body_elt) | |
303 for subject_elt in list(stanza_elt.elements(None, "subject")): | |
304 stanza_elt.children.remove(subject_elt) | |
305 | |
306 encrypted_elt = stanza_elt.addElement( | |
307 (NS_GRE, "encrypted"), content=encrypted_payload | |
308 ) | |
309 encrypted_elt["formatter"] = formatter.namespace | |
310 encrypted_elt["encrypter"] = encrypter.namespace | |
311 | |
312 return True | |
313 | |
314 async def get_data( | |
315 self, client: SatXMPPEntity, recipient_jid: jid.JID | |
316 ) -> data_form.Form: | |
317 """Retrieve relayed encryption data form. | |
318 | |
319 @param client: Client session. | |
320 @param recipient_id: Bare jid of the entity to whom we want to send encrypted | |
321 mesasge. | |
322 @return: Found data form, or None if no data form has been found. | |
323 """ | |
324 assert recipient_jid.resource is None, "recipient_jid must be a bare jid." | |
325 iq_elt = client.IQ("get") | |
326 iq_elt["to"] = recipient_jid.full() | |
327 data_elt = iq_elt.addElement((NS_GRE, "data")) | |
328 iq_result_elt = await iq_elt.send() | |
329 try: | |
330 data_elt = next(iq_result_elt.elements(NS_GRE, "data")) | |
331 except StopIteration: | |
332 raise exceptions.DataError( | |
333 f"Relayed data payload is missing: {iq_result_elt.toXml()}" | |
334 ) | |
335 form = data_form.findForm(data_elt, NS_GRE_DATA) | |
336 if form is None: | |
337 raise exceptions.DataError( | |
338 f"Relayed data form is missing: {iq_result_elt.toXml()}" | |
339 ) | |
340 return form | |
341 | |
342 async def get_trust_ui( | |
343 self, client: SatXMPPEntity, entity: jid.JID | |
344 ) -> xml_tools.XMLUI: | |
345 """ | |
346 @param client: The client session. | |
347 @param entity: The entity whose device trust levels to manage. | |
348 @return: An XMLUI Dialog to handle trust for given entity. | |
349 """ | |
350 # We just return an enmpty form for now. | |
351 return xml_tools.XMLUI(C.XMLUI_FORM) | |
352 | |
353 def get_handler(self, client: SatXMPPEntity) -> XMPPHandler: | |
354 return GREHandler(self) | |
355 | |
356 | |
357 @implementer(iwokkel.IDisco) | |
358 class GREHandler(XMPPHandler): | |
359 | |
360 def __init__(self, plugin_parent: GRE) -> None: | |
361 self.plugin_parent = plugin_parent | |
362 | |
363 def connectionInitialized(self): | |
364 assert self.parent is not None and self.xmlstream is not None | |
365 if self.parent.is_component: | |
366 self.xmlstream.addObserver( | |
367 IQ_DATA_REQUEST, | |
368 self.plugin_parent._on_component_data_request, | |
369 client=self.parent, | |
370 ) | |
371 | |
372 def getDiscoInfo( | |
373 self, requestor: jid.JID, target: jid.JID, nodeIdentifier: str = "" | |
374 ) -> list[disco.DiscoFeature]: | |
375 return [ | |
376 disco.DiscoFeature(NS_GRE), | |
377 ] | |
378 | |
379 def getDiscoItems( | |
380 self, requestor: jid.JID, target: jid.JID, nodeIdentifier: str = "" | |
381 ) -> list[disco.DiscoItems]: | |
382 return [] |