changeset 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 1877c5c477ec
children 6c36eb30afb8
files sat/plugins/plugin_xep_0448.py
diffstat 1 files changed, 469 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sat/plugins/plugin_xep_0448.py	Thu Oct 06 16:02:05 2022 +0200
@@ -0,0 +1,469 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for handling stateless file sharing encryption
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import base64
+from functools import partial
+from pathlib import Path
+import secrets
+from textwrap import dedent
+from typing import Any, Dict, Optional, Tuple, Union
+
+from cryptography.exceptions import AlreadyFinalized
+from cryptography.hazmat import backends
+from cryptography.hazmat.primitives import ciphers
+from cryptography.hazmat.primitives.ciphers import CipherContext, modes
+from cryptography.hazmat.primitives.padding import PKCS7, PaddingContext
+import treq
+from twisted.internet import defer
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+from build.lib.sat.plugins.plugin_xep_0054 import IMPORT_NAME
+from sat.core import exceptions
+from sat.core.constants import Const as C
+from sat.core.core_types import SatXMPPEntity
+from sat.core.i18n import _
+from sat.core.log import getLogger
+from sat.tools import stream
+from sat.tools.web import treq_client_no_ssl
+
+log = getLogger(__name__)
+
+IMPORT_NAME = "XEP-0448"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Encryption for Stateless File Sharing",
+    C.PI_IMPORT_NAME: IMPORT_NAME,
+    C.PI_TYPE: C.PLUG_TYPE_EXP,
+    C.PI_PROTOCOLS: ["XEP-0448"],
+    C.PI_DEPENDENCIES: [
+        "XEP-0103", "XEP-0300", "XEP-0334", "XEP-0363", "XEP-0384", "XEP-0447",
+        "DOWNLOAD", "ATTACH"
+    ],
+    C.PI_MAIN: "XEP_0448",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: dedent(_("""\
+    Implementation of e2e encryption for media sharing
+    """)),
+}
+
+NS_ESFS = "urn:xmpp:esfs:0"
+NS_AES_128_GCM = "urn:xmpp:ciphers:aes-128-gcm-nopadding:0"
+NS_AES_256_GCM = "urn:xmpp:ciphers:aes-256-gcm-nopadding:0"
+NS_AES_256_CBC = "urn:xmpp:ciphers:aes-256-cbc-pkcs7:0"
+
+
+class XEP_0448:
+
+    def __init__(self, host):
+        self.host = host
+        log.info(_("XEP_0448 plugin initialization"))
+        host.registerNamespace("esfs", NS_ESFS)
+        self._u = host.plugins["XEP-0103"]
+        self._h = host.plugins["XEP-0300"]
+        self._hints = host.plugins["XEP-0334"]
+        self._http_upload = host.plugins["XEP-0363"]
+        self._o = host.plugins["XEP-0384"]
+        self._sfs = host.plugins["XEP-0447"]
+        self._sfs.register_source_handler(
+            NS_ESFS, "encrypted", self.parse_encrypted_elt, encrypted=True
+        )
+        self._attach = host.plugins["ATTACH"]
+        self._attach.register(
+            self.can_handle_attachment, self.attach, encrypted=True, priority=1000
+        )
+        host.plugins["DOWNLOAD"].register_download_handler(NS_ESFS, self.download)
+        host.trigger.add("XEP-0363_upload_pre_slot", self._upload_pre_slot)
+        host.trigger.add("XEP-0363_upload", self._upload_trigger)
+
+    def getHandler(self, client):
+        return XEP0448Handler()
+
+    def parse_encrypted_elt(self, encrypted_elt: domish.Element) -> Dict[str, Any]:
+        """Parse an <encrypted> element and return corresponding source data
+
+        @param encrypted_elt: element to parse
+        @raise exceptions.DataError: the element is invalid
+
+        """
+        sources = self._sfs.parse_sources_elt(encrypted_elt)
+        if not sources:
+            raise exceptions.NotFound("sources are missing in {encrypted_elt.toXml()}")
+        if len(sources) > 1:
+            log.debug(
+                "more that one sources has been found, this is not expected, only the "
+                "first one will be used"
+            )
+        source = sources[0]
+        source["type"] = NS_ESFS
+        try:
+            encrypted_data = source["encrypted_data"] = {
+                "cipher": encrypted_elt["cipher"],
+                "key": str(next(encrypted_elt.elements(NS_ESFS, "key"))),
+                "iv": str(next(encrypted_elt.elements(NS_ESFS, "iv"))),
+            }
+        except (KeyError, StopIteration):
+            raise exceptions.DataError(
+                "invalid <encrypted/> element: {encrypted_elt.toXml()}"
+            )
+        try:
+            hash_algo, hash_value = self._h.parseHashElt(encrypted_elt)
+        except exceptions.NotFound:
+            pass
+        else:
+            encrypted_data["hash_algo"] = hash_algo
+            encrypted_data["hash"] = base64.b64encode(hash_value.encode()).decode()
+        return source
+
+    async def download(
+        self,
+        client: SatXMPPEntity,
+        attachment: Dict[str, Any],
+        source: Dict[str, Any],
+        dest_path: Union[Path, str],
+        extra: Optional[Dict[str, Any]] = None
+    ) -> Tuple[str, defer.Deferred]:
+        # TODO: check hash
+        if extra is None:
+            extra = {}
+        try:
+            encrypted_data = source["encrypted_data"]
+            cipher = encrypted_data["cipher"]
+            iv = base64.b64decode(encrypted_data["iv"])
+            key = base64.b64decode(encrypted_data["key"])
+        except KeyError as e:
+            raise ValueError(f"{source} has incomplete encryption data: {e}")
+        try:
+            download_url = source["url"]
+        except KeyError:
+            raise ValueError(f"{source} has missing URL")
+
+        if extra.get('ignore_tls_errors', False):
+            log.warning(
+                "TLS certificate check disabled, this is highly insecure"
+            )
+            treq_client = treq_client_no_ssl
+        else:
+            treq_client = treq
+
+        try:
+            file_size = int(attachment["size"])
+        except (KeyError, ValueError):
+            head_data = await treq_client.head(download_url)
+            content_length = int(head_data.headers.getRawHeaders('content-length')[0])
+            # the 128 bits tag is put at the end
+            file_size = content_length - 16
+
+        file_obj = stream.SatFile(
+            self.host,
+            client,
+            dest_path,
+            mode="wb",
+            size = file_size,
+        )
+
+        if cipher in (NS_AES_128_GCM, NS_AES_256_GCM):
+            decryptor = ciphers.Cipher(
+                ciphers.algorithms.AES(key),
+                modes.GCM(iv),
+                backend=backends.default_backend(),
+            ).decryptor()
+            decrypt_cb = partial(
+                self.gcm_decrypt,
+                client=client,
+                file_obj=file_obj,
+                decryptor=decryptor,
+            )
+            finalize_cb = None
+        elif cipher == NS_AES_256_CBC:
+            cipher_algo = ciphers.algorithms.AES(key)
+            decryptor = ciphers.Cipher(
+                cipher_algo,
+                modes.CBC(iv),
+                backend=backends.default_backend(),
+            ).decryptor()
+            unpadder = PKCS7(cipher_algo.block_size).unpadder()
+            decrypt_cb = partial(
+                self.cbc_decrypt,
+                client=client,
+                file_obj=file_obj,
+                decryptor=decryptor,
+                unpadder=unpadder
+            )
+            finalize_cb = partial(
+                self.cbc_decrypt_finalize,
+                file_obj=file_obj,
+                decryptor=decryptor,
+                unpadder=unpadder
+            )
+        else:
+            msg = f"cipher {cipher!r} is not supported"
+            file_obj.close(error=msg)
+            log.warning(msg)
+            raise exceptions.CancelError(msg)
+
+        progress_id = file_obj.uid
+
+        resp = await treq_client.get(download_url, unbuffered=True)
+        if resp.code == 200:
+            d = treq.collect(resp, partial(decrypt_cb))
+            if finalize_cb is not None:
+                d.addCallback(lambda __: finalize_cb())
+        else:
+            d = defer.Deferred()
+            self.host.plugins["DOWNLOAD"].errback_download(file_obj, d, resp)
+        return progress_id, d
+
+    async def can_handle_attachment(self, client, data):
+        # FIXME: check if SCE is supported without checking which e2ee algo is used
+        if client.encryption.get_namespace(data["to"]) != self._o.NS_TWOMEMO:
+            # we need SCE, and it is currently supported only by TWOMEMO, thus we can't
+            # handle the attachment if it's not activated
+            return False
+        try:
+            await self._http_upload.getHTTPUploadEntity(client)
+        except exceptions.NotFound:
+            return False
+        else:
+            return True
+
+    async def _upload_cb(self, client, filepath, filename, extra):
+        attachment = extra["attachment"]
+        extra["encryption"] = IMPORT_NAME
+        attachment["encryption_data"] = extra["encryption_data"] = {
+            "algorithm": C.ENC_AES_GCM,
+            "iv": secrets.token_bytes(12),
+            "key": secrets.token_bytes(32),
+        }
+        attachment["filename"] = filename
+        return await self._http_upload.file_http_upload(
+            client=client,
+            filepath=filepath,
+            filename="encrypted",
+            extra=extra
+        )
+
+    async def attach(self, client, data):
+        # XXX: for now, XEP-0447/XEP-0448 only allow to send one file per <message/>, thus
+        #   we need to send each file in a separate message, in the same way as for
+        #   plugin_sec_aesgcm.
+        attachments = data["extra"][C.MESS_KEY_ATTACHMENTS]
+        if not data['message'] or data['message'] == {'': ''}:
+            extra_attachments = attachments[1:]
+            del attachments[1:]
+        else:
+            # we have a message, we must send first attachment separately
+            extra_attachments = attachments[:]
+            attachments.clear()
+            del data["extra"][C.MESS_KEY_ATTACHMENTS]
+
+        if attachments:
+            if len(attachments) > 1:
+                raise exceptions.InternalError(
+                    "There should not be more that one attachment at this point"
+                )
+            await self._attach.upload_files(client, data, upload_cb=self._upload_cb)
+            self._hints.addHintElements(data["xml"], [self._hints.HINT_STORE])
+            for attachment in attachments:
+                encryption_data = attachment.pop("encryption_data")
+                file_hash = (attachment["hash_algo"], attachment["hash"])
+                file_sharing_elt = self._sfs.get_file_sharing_elt(
+                    [],
+                    name=attachment["filename"],
+                    size=attachment["size"],
+                    file_hash=file_hash
+                )
+                encrypted_elt = file_sharing_elt.sources.addElement(
+                    (NS_ESFS, "encrypted")
+                )
+                encrypted_elt["cipher"] = NS_AES_256_GCM
+                encrypted_elt.addElement(
+                    "key",
+                    content=base64.b64encode(encryption_data["key"]).decode()
+                )
+                encrypted_elt.addElement(
+                    "iv",
+                    content=base64.b64encode(encryption_data["iv"]).decode()
+                )
+                encrypted_elt.addChild(self._h.buildHashElt(
+                    attachment["encrypted_hash"],
+                    attachment["encrypted_hash_algo"]
+                ))
+                encrypted_elt.addChild(
+                    self._sfs.get_sources_elt(
+                        [self._u.get_url_data_elt(attachment["url"])]
+                    )
+                )
+                data["xml"].addChild(file_sharing_elt)
+
+        for attachment in extra_attachments:
+            # we send all remaining attachment in a separate message
+            await client.sendMessage(
+                to_jid=data['to'],
+                message={'': ''},
+                subject=data['subject'],
+                mess_type=data['type'],
+                extra={C.MESS_KEY_ATTACHMENTS: [attachment]},
+            )
+
+        if ((not data['extra']
+             and (not data['message'] or data['message'] == {'': ''})
+             and not data['subject'])):
+            # nothing left to send, we can cancel the message
+            raise exceptions.CancelError("Cancelled by XEP_0448 attachment handling")
+
+    def gcm_decrypt(
+        self,
+        data: bytes,
+        client: SatXMPPEntity,
+        file_obj: stream.SatFile,
+        decryptor: CipherContext
+    ) -> None:
+        if file_obj.tell() + len(data) > file_obj.size:  # type: ignore
+            # we're reaching end of file with this bunch of data
+            # we may still have a last bunch if the tag is incomplete
+            bytes_left = file_obj.size - file_obj.tell()  # type: ignore
+            if bytes_left > 0:
+                decrypted = decryptor.update(data[:bytes_left])
+                file_obj.write(decrypted)
+                tag = data[bytes_left:]
+            else:
+                tag = data
+            if len(tag) < 16:
+                # the tag is incomplete, either we'll get the rest in next data bunch
+                # or we have already the other part from last bunch of data
+                try:
+                    # we store partial tag in decryptor._sat_tag
+                    tag = decryptor._sat_tag + tag
+                except AttributeError:
+                    # no other part, we'll get the rest at next bunch
+                    decryptor.sat_tag = tag
+                else:
+                    # we have the complete tag, it must be 128 bits
+                    if len(tag) != 16:
+                        raise ValueError(f"Invalid tag: {tag}")
+            remain = decryptor.finalize_with_tag(tag)
+            file_obj.write(remain)
+            file_obj.close()
+        else:
+            decrypted = decryptor.update(data)
+            file_obj.write(decrypted)
+
+    def cbc_decrypt(
+        self,
+        data: bytes,
+        client: SatXMPPEntity,
+        file_obj: stream.SatFile,
+        decryptor: CipherContext,
+        unpadder: PaddingContext
+    ) -> None:
+        decrypted = decryptor.update(data)
+        file_obj.write(unpadder.update(decrypted))
+
+    def cbc_decrypt_finalize(
+        self,
+        file_obj: stream.SatFile,
+        decryptor: CipherContext,
+        unpadder: PaddingContext
+    ) -> None:
+        decrypted = decryptor.finalize()
+        file_obj.write(unpadder.update(decrypted))
+        file_obj.write(unpadder.finalize())
+        file_obj.close()
+
+    def _upload_pre_slot(self, client, extra, file_metadata):
+        if extra.get('encryption') != IMPORT_NAME:
+            return True
+        # the tag is appended to the file
+        file_metadata["size"] += 16
+        return True
+
+    def _encrypt(self, data: bytes, encryptor: CipherContext, attachment: dict) -> bytes:
+        if data:
+            attachment["hasher"].update(data)
+            ret = encryptor.update(data)
+            attachment["encrypted_hasher"].update(ret)
+            return ret
+        else:
+            try:
+                # end of file is reached, me must finalize
+                fin = encryptor.finalize()
+                tag = encryptor.tag
+                ret = fin + tag
+                hasher = attachment.pop("hasher")
+                attachment["hash"] = hasher.hexdigest()
+                encrypted_hasher = attachment.pop("encrypted_hasher")
+                encrypted_hasher.update(ret)
+                attachment["encrypted_hash"] = encrypted_hasher.hexdigest()
+                return ret
+            except AlreadyFinalized:
+                # as we have already finalized, we can now send EOF
+                return b''
+
+    def _upload_trigger(self, client, extra, sat_file, file_producer, slot):
+        if extra.get('encryption') != IMPORT_NAME:
+            return True
+        attachment = extra["attachment"]
+        encryption_data = extra["encryption_data"]
+        log.debug("encrypting file with AES-GCM")
+        iv = encryption_data["iv"]
+        key = encryption_data["key"]
+
+        # encrypted data size will be bigger than original file size
+        # so we need to check with final data length to avoid a warning on close()
+        sat_file.check_size_with_read = True
+
+        # file_producer get length directly from file, and this cause trouble as
+        # we have to change the size because of encryption. So we adapt it here,
+        # else the producer would stop reading prematurely
+        file_producer.length = sat_file.size
+
+        encryptor = ciphers.Cipher(
+            ciphers.algorithms.AES(key),
+            modes.GCM(iv),
+            backend=backends.default_backend(),
+        ).encryptor()
+
+        if sat_file.data_cb is not None:
+            raise exceptions.InternalError(
+                f"data_cb was expected to be None, it is set to {sat_file.data_cb}")
+
+        attachment.update({
+            "hash_algo": self._h.ALGO_DEFAULT,
+            "hasher": self._h.getHasher(),
+            "encrypted_hash_algo": self._h.ALGO_DEFAULT,
+            "encrypted_hasher": self._h.getHasher(),
+        })
+
+        # with data_cb we encrypt the file on the fly
+        sat_file.data_cb = partial(
+            self._encrypt, encryptor=encryptor, attachment=attachment
+        )
+        return True
+
+
+@implementer(iwokkel.IDisco)
+class XEP0448Handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_ESFS)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []