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