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