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 []