diff --git a/sat/plugins/plugin_xep_0448.py b/sat/plugins/plugin_xep_0448.py
new file mode 100644
index 00000000..73ddf93b
--- /dev/null
+++ b/sat/plugins/plugin_xep_0448.py
@@ -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 .
+
+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 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 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 , 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 []