Mercurial > libervia-backend
comparison libervia/backend/plugins/plugin_xep_0448.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/plugins/plugin_xep_0448.py@524856bd7b19 |
children | 0d7bb4df2343 |
comparison
equal
deleted
inserted
replaced
4070:d10748475025 | 4071:4b842c1fb686 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # Libervia plugin for handling stateless file sharing encryption | |
4 # Copyright (C) 2009-2022 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 import base64 | |
20 from functools import partial | |
21 from pathlib import Path | |
22 import secrets | |
23 from textwrap import dedent | |
24 from typing import Any, Dict, Optional, Tuple, Union | |
25 | |
26 from cryptography.exceptions import AlreadyFinalized | |
27 from cryptography.hazmat import backends | |
28 from cryptography.hazmat.primitives import ciphers | |
29 from cryptography.hazmat.primitives.ciphers import CipherContext, modes | |
30 from cryptography.hazmat.primitives.padding import PKCS7, PaddingContext | |
31 import treq | |
32 from twisted.internet import defer | |
33 from twisted.words.protocols.jabber.xmlstream import XMPPHandler | |
34 from twisted.words.xish import domish | |
35 from wokkel import disco, iwokkel | |
36 from zope.interface import implementer | |
37 | |
38 from libervia.backend.core import exceptions | |
39 from libervia.backend.core.constants import Const as C | |
40 from libervia.backend.core.core_types import SatXMPPEntity | |
41 from libervia.backend.core.i18n import _ | |
42 from libervia.backend.core.log import getLogger | |
43 from libervia.backend.tools import stream | |
44 from libervia.backend.tools.web import treq_client_no_ssl | |
45 | |
46 log = getLogger(__name__) | |
47 | |
48 IMPORT_NAME = "XEP-0448" | |
49 | |
50 PLUGIN_INFO = { | |
51 C.PI_NAME: "Encryption for Stateless File Sharing", | |
52 C.PI_IMPORT_NAME: IMPORT_NAME, | |
53 C.PI_TYPE: C.PLUG_TYPE_EXP, | |
54 C.PI_PROTOCOLS: ["XEP-0448"], | |
55 C.PI_DEPENDENCIES: [ | |
56 "XEP-0103", "XEP-0300", "XEP-0334", "XEP-0363", "XEP-0384", "XEP-0447", | |
57 "DOWNLOAD", "ATTACH" | |
58 ], | |
59 C.PI_MAIN: "XEP_0448", | |
60 C.PI_HANDLER: "yes", | |
61 C.PI_DESCRIPTION: dedent(_("""\ | |
62 Implementation of e2e encryption for media sharing | |
63 """)), | |
64 } | |
65 | |
66 NS_ESFS = "urn:xmpp:esfs:0" | |
67 NS_AES_128_GCM = "urn:xmpp:ciphers:aes-128-gcm-nopadding:0" | |
68 NS_AES_256_GCM = "urn:xmpp:ciphers:aes-256-gcm-nopadding:0" | |
69 NS_AES_256_CBC = "urn:xmpp:ciphers:aes-256-cbc-pkcs7:0" | |
70 | |
71 | |
72 class XEP_0448: | |
73 | |
74 def __init__(self, host): | |
75 self.host = host | |
76 log.info(_("XEP_0448 plugin initialization")) | |
77 host.register_namespace("esfs", NS_ESFS) | |
78 self._u = host.plugins["XEP-0103"] | |
79 self._h = host.plugins["XEP-0300"] | |
80 self._hints = host.plugins["XEP-0334"] | |
81 self._http_upload = host.plugins["XEP-0363"] | |
82 self._o = host.plugins["XEP-0384"] | |
83 self._sfs = host.plugins["XEP-0447"] | |
84 self._sfs.register_source_handler( | |
85 NS_ESFS, "encrypted", self.parse_encrypted_elt, encrypted=True | |
86 ) | |
87 self._attach = host.plugins["ATTACH"] | |
88 self._attach.register( | |
89 self.can_handle_attachment, self.attach, encrypted=True, priority=1000 | |
90 ) | |
91 host.plugins["DOWNLOAD"].register_download_handler(NS_ESFS, self.download) | |
92 host.trigger.add("XEP-0363_upload_pre_slot", self._upload_pre_slot) | |
93 host.trigger.add("XEP-0363_upload", self._upload_trigger) | |
94 | |
95 def get_handler(self, client): | |
96 return XEP0448Handler() | |
97 | |
98 def parse_encrypted_elt(self, encrypted_elt: domish.Element) -> Dict[str, Any]: | |
99 """Parse an <encrypted> element and return corresponding source data | |
100 | |
101 @param encrypted_elt: element to parse | |
102 @raise exceptions.DataError: the element is invalid | |
103 | |
104 """ | |
105 sources = self._sfs.parse_sources_elt(encrypted_elt) | |
106 if not sources: | |
107 raise exceptions.NotFound("sources are missing in {encrypted_elt.toXml()}") | |
108 if len(sources) > 1: | |
109 log.debug( | |
110 "more that one sources has been found, this is not expected, only the " | |
111 "first one will be used" | |
112 ) | |
113 source = sources[0] | |
114 source["type"] = NS_ESFS | |
115 try: | |
116 encrypted_data = source["encrypted_data"] = { | |
117 "cipher": encrypted_elt["cipher"], | |
118 "key": str(next(encrypted_elt.elements(NS_ESFS, "key"))), | |
119 "iv": str(next(encrypted_elt.elements(NS_ESFS, "iv"))), | |
120 } | |
121 except (KeyError, StopIteration): | |
122 raise exceptions.DataError( | |
123 "invalid <encrypted/> element: {encrypted_elt.toXml()}" | |
124 ) | |
125 try: | |
126 hash_algo, hash_value = self._h.parse_hash_elt(encrypted_elt) | |
127 except exceptions.NotFound: | |
128 pass | |
129 else: | |
130 encrypted_data["hash_algo"] = hash_algo | |
131 encrypted_data["hash"] = base64.b64encode(hash_value.encode()).decode() | |
132 return source | |
133 | |
134 async def download( | |
135 self, | |
136 client: SatXMPPEntity, | |
137 attachment: Dict[str, Any], | |
138 source: Dict[str, Any], | |
139 dest_path: Union[Path, str], | |
140 extra: Optional[Dict[str, Any]] = None | |
141 ) -> Tuple[str, defer.Deferred]: | |
142 # TODO: check hash | |
143 if extra is None: | |
144 extra = {} | |
145 try: | |
146 encrypted_data = source["encrypted_data"] | |
147 cipher = encrypted_data["cipher"] | |
148 iv = base64.b64decode(encrypted_data["iv"]) | |
149 key = base64.b64decode(encrypted_data["key"]) | |
150 except KeyError as e: | |
151 raise ValueError(f"{source} has incomplete encryption data: {e}") | |
152 try: | |
153 download_url = source["url"] | |
154 except KeyError: | |
155 raise ValueError(f"{source} has missing URL") | |
156 | |
157 if extra.get('ignore_tls_errors', False): | |
158 log.warning( | |
159 "TLS certificate check disabled, this is highly insecure" | |
160 ) | |
161 treq_client = treq_client_no_ssl | |
162 else: | |
163 treq_client = treq | |
164 | |
165 try: | |
166 file_size = int(attachment["size"]) | |
167 except (KeyError, ValueError): | |
168 head_data = await treq_client.head(download_url) | |
169 content_length = int(head_data.headers.getRawHeaders('content-length')[0]) | |
170 # the 128 bits tag is put at the end | |
171 file_size = content_length - 16 | |
172 | |
173 file_obj = stream.SatFile( | |
174 self.host, | |
175 client, | |
176 dest_path, | |
177 mode="wb", | |
178 size = file_size, | |
179 ) | |
180 | |
181 if cipher in (NS_AES_128_GCM, NS_AES_256_GCM): | |
182 decryptor = ciphers.Cipher( | |
183 ciphers.algorithms.AES(key), | |
184 modes.GCM(iv), | |
185 backend=backends.default_backend(), | |
186 ).decryptor() | |
187 decrypt_cb = partial( | |
188 self.gcm_decrypt, | |
189 client=client, | |
190 file_obj=file_obj, | |
191 decryptor=decryptor, | |
192 ) | |
193 finalize_cb = None | |
194 elif cipher == NS_AES_256_CBC: | |
195 cipher_algo = ciphers.algorithms.AES(key) | |
196 decryptor = ciphers.Cipher( | |
197 cipher_algo, | |
198 modes.CBC(iv), | |
199 backend=backends.default_backend(), | |
200 ).decryptor() | |
201 unpadder = PKCS7(cipher_algo.block_size).unpadder() | |
202 decrypt_cb = partial( | |
203 self.cbc_decrypt, | |
204 client=client, | |
205 file_obj=file_obj, | |
206 decryptor=decryptor, | |
207 unpadder=unpadder | |
208 ) | |
209 finalize_cb = partial( | |
210 self.cbc_decrypt_finalize, | |
211 file_obj=file_obj, | |
212 decryptor=decryptor, | |
213 unpadder=unpadder | |
214 ) | |
215 else: | |
216 msg = f"cipher {cipher!r} is not supported" | |
217 file_obj.close(error=msg) | |
218 log.warning(msg) | |
219 raise exceptions.CancelError(msg) | |
220 | |
221 progress_id = file_obj.uid | |
222 | |
223 resp = await treq_client.get(download_url, unbuffered=True) | |
224 if resp.code == 200: | |
225 d = treq.collect(resp, partial(decrypt_cb)) | |
226 if finalize_cb is not None: | |
227 d.addCallback(lambda __: finalize_cb()) | |
228 else: | |
229 d = defer.Deferred() | |
230 self.host.plugins["DOWNLOAD"].errback_download(file_obj, d, resp) | |
231 return progress_id, d | |
232 | |
233 async def can_handle_attachment(self, client, data): | |
234 # FIXME: check if SCE is supported without checking which e2ee algo is used | |
235 if client.encryption.get_namespace(data["to"]) != self._o.NS_TWOMEMO: | |
236 # we need SCE, and it is currently supported only by TWOMEMO, thus we can't | |
237 # handle the attachment if it's not activated | |
238 return False | |
239 try: | |
240 await self._http_upload.get_http_upload_entity(client) | |
241 except exceptions.NotFound: | |
242 return False | |
243 else: | |
244 return True | |
245 | |
246 async def _upload_cb(self, client, filepath, filename, extra): | |
247 attachment = extra["attachment"] | |
248 extra["encryption"] = IMPORT_NAME | |
249 attachment["encryption_data"] = extra["encryption_data"] = { | |
250 "algorithm": C.ENC_AES_GCM, | |
251 "iv": secrets.token_bytes(12), | |
252 "key": secrets.token_bytes(32), | |
253 } | |
254 attachment["filename"] = filename | |
255 return await self._http_upload.file_http_upload( | |
256 client=client, | |
257 filepath=filepath, | |
258 filename="encrypted", | |
259 extra=extra | |
260 ) | |
261 | |
262 async def attach(self, client, data): | |
263 # XXX: for now, XEP-0447/XEP-0448 only allow to send one file per <message/>, thus | |
264 # we need to send each file in a separate message, in the same way as for | |
265 # plugin_sec_aesgcm. | |
266 attachments = data["extra"][C.KEY_ATTACHMENTS] | |
267 if not data['message'] or data['message'] == {'': ''}: | |
268 extra_attachments = attachments[1:] | |
269 del attachments[1:] | |
270 else: | |
271 # we have a message, we must send first attachment separately | |
272 extra_attachments = attachments[:] | |
273 attachments.clear() | |
274 del data["extra"][C.KEY_ATTACHMENTS] | |
275 | |
276 if attachments: | |
277 if len(attachments) > 1: | |
278 raise exceptions.InternalError( | |
279 "There should not be more that one attachment at this point" | |
280 ) | |
281 await self._attach.upload_files(client, data, upload_cb=self._upload_cb) | |
282 self._hints.add_hint_elements(data["xml"], [self._hints.HINT_STORE]) | |
283 for attachment in attachments: | |
284 encryption_data = attachment.pop("encryption_data") | |
285 file_hash = (attachment["hash_algo"], attachment["hash"]) | |
286 file_sharing_elt = self._sfs.get_file_sharing_elt( | |
287 [], | |
288 name=attachment["filename"], | |
289 size=attachment["size"], | |
290 file_hash=file_hash | |
291 ) | |
292 encrypted_elt = file_sharing_elt.sources.addElement( | |
293 (NS_ESFS, "encrypted") | |
294 ) | |
295 encrypted_elt["cipher"] = NS_AES_256_GCM | |
296 encrypted_elt.addElement( | |
297 "key", | |
298 content=base64.b64encode(encryption_data["key"]).decode() | |
299 ) | |
300 encrypted_elt.addElement( | |
301 "iv", | |
302 content=base64.b64encode(encryption_data["iv"]).decode() | |
303 ) | |
304 encrypted_elt.addChild(self._h.build_hash_elt( | |
305 attachment["encrypted_hash"], | |
306 attachment["encrypted_hash_algo"] | |
307 )) | |
308 encrypted_elt.addChild( | |
309 self._sfs.get_sources_elt( | |
310 [self._u.get_url_data_elt(attachment["url"])] | |
311 ) | |
312 ) | |
313 data["xml"].addChild(file_sharing_elt) | |
314 | |
315 for attachment in extra_attachments: | |
316 # we send all remaining attachment in a separate message | |
317 await client.sendMessage( | |
318 to_jid=data['to'], | |
319 message={'': ''}, | |
320 subject=data['subject'], | |
321 mess_type=data['type'], | |
322 extra={C.KEY_ATTACHMENTS: [attachment]}, | |
323 ) | |
324 | |
325 if ((not data['extra'] | |
326 and (not data['message'] or data['message'] == {'': ''}) | |
327 and not data['subject'])): | |
328 # nothing left to send, we can cancel the message | |
329 raise exceptions.CancelError("Cancelled by XEP_0448 attachment handling") | |
330 | |
331 def gcm_decrypt( | |
332 self, | |
333 data: bytes, | |
334 client: SatXMPPEntity, | |
335 file_obj: stream.SatFile, | |
336 decryptor: CipherContext | |
337 ) -> None: | |
338 if file_obj.tell() + len(data) > file_obj.size: # type: ignore | |
339 # we're reaching end of file with this bunch of data | |
340 # we may still have a last bunch if the tag is incomplete | |
341 bytes_left = file_obj.size - file_obj.tell() # type: ignore | |
342 if bytes_left > 0: | |
343 decrypted = decryptor.update(data[:bytes_left]) | |
344 file_obj.write(decrypted) | |
345 tag = data[bytes_left:] | |
346 else: | |
347 tag = data | |
348 if len(tag) < 16: | |
349 # the tag is incomplete, either we'll get the rest in next data bunch | |
350 # or we have already the other part from last bunch of data | |
351 try: | |
352 # we store partial tag in decryptor._sat_tag | |
353 tag = decryptor._sat_tag + tag | |
354 except AttributeError: | |
355 # no other part, we'll get the rest at next bunch | |
356 decryptor.sat_tag = tag | |
357 else: | |
358 # we have the complete tag, it must be 128 bits | |
359 if len(tag) != 16: | |
360 raise ValueError(f"Invalid tag: {tag}") | |
361 remain = decryptor.finalize_with_tag(tag) | |
362 file_obj.write(remain) | |
363 file_obj.close() | |
364 else: | |
365 decrypted = decryptor.update(data) | |
366 file_obj.write(decrypted) | |
367 | |
368 def cbc_decrypt( | |
369 self, | |
370 data: bytes, | |
371 client: SatXMPPEntity, | |
372 file_obj: stream.SatFile, | |
373 decryptor: CipherContext, | |
374 unpadder: PaddingContext | |
375 ) -> None: | |
376 decrypted = decryptor.update(data) | |
377 file_obj.write(unpadder.update(decrypted)) | |
378 | |
379 def cbc_decrypt_finalize( | |
380 self, | |
381 file_obj: stream.SatFile, | |
382 decryptor: CipherContext, | |
383 unpadder: PaddingContext | |
384 ) -> None: | |
385 decrypted = decryptor.finalize() | |
386 file_obj.write(unpadder.update(decrypted)) | |
387 file_obj.write(unpadder.finalize()) | |
388 file_obj.close() | |
389 | |
390 def _upload_pre_slot(self, client, extra, file_metadata): | |
391 if extra.get('encryption') != IMPORT_NAME: | |
392 return True | |
393 # the tag is appended to the file | |
394 file_metadata["size"] += 16 | |
395 return True | |
396 | |
397 def _encrypt(self, data: bytes, encryptor: CipherContext, attachment: dict) -> bytes: | |
398 if data: | |
399 attachment["hasher"].update(data) | |
400 ret = encryptor.update(data) | |
401 attachment["encrypted_hasher"].update(ret) | |
402 return ret | |
403 else: | |
404 try: | |
405 # end of file is reached, me must finalize | |
406 fin = encryptor.finalize() | |
407 tag = encryptor.tag | |
408 ret = fin + tag | |
409 hasher = attachment.pop("hasher") | |
410 attachment["hash"] = hasher.hexdigest() | |
411 encrypted_hasher = attachment.pop("encrypted_hasher") | |
412 encrypted_hasher.update(ret) | |
413 attachment["encrypted_hash"] = encrypted_hasher.hexdigest() | |
414 return ret | |
415 except AlreadyFinalized: | |
416 # as we have already finalized, we can now send EOF | |
417 return b'' | |
418 | |
419 def _upload_trigger(self, client, extra, sat_file, file_producer, slot): | |
420 if extra.get('encryption') != IMPORT_NAME: | |
421 return True | |
422 attachment = extra["attachment"] | |
423 encryption_data = extra["encryption_data"] | |
424 log.debug("encrypting file with AES-GCM") | |
425 iv = encryption_data["iv"] | |
426 key = encryption_data["key"] | |
427 | |
428 # encrypted data size will be bigger than original file size | |
429 # so we need to check with final data length to avoid a warning on close() | |
430 sat_file.check_size_with_read = True | |
431 | |
432 # file_producer get length directly from file, and this cause trouble as | |
433 # we have to change the size because of encryption. So we adapt it here, | |
434 # else the producer would stop reading prematurely | |
435 file_producer.length = sat_file.size | |
436 | |
437 encryptor = ciphers.Cipher( | |
438 ciphers.algorithms.AES(key), | |
439 modes.GCM(iv), | |
440 backend=backends.default_backend(), | |
441 ).encryptor() | |
442 | |
443 if sat_file.data_cb is not None: | |
444 raise exceptions.InternalError( | |
445 f"data_cb was expected to be None, it is set to {sat_file.data_cb}") | |
446 | |
447 attachment.update({ | |
448 "hash_algo": self._h.ALGO_DEFAULT, | |
449 "hasher": self._h.get_hasher(), | |
450 "encrypted_hash_algo": self._h.ALGO_DEFAULT, | |
451 "encrypted_hasher": self._h.get_hasher(), | |
452 }) | |
453 | |
454 # with data_cb we encrypt the file on the fly | |
455 sat_file.data_cb = partial( | |
456 self._encrypt, encryptor=encryptor, attachment=attachment | |
457 ) | |
458 return True | |
459 | |
460 | |
461 @implementer(iwokkel.IDisco) | |
462 class XEP0448Handler(XMPPHandler): | |
463 | |
464 def getDiscoInfo(self, requestor, target, nodeIdentifier=""): | |
465 return [disco.DiscoFeature(NS_ESFS)] | |
466 | |
467 def getDiscoItems(self, requestor, target, nodeIdentifier=""): | |
468 return [] |