# HG changeset patch # User Goffi # Date 1576841284 -3600 # Node ID 4f8bdf50593f01eca5b95b221783eb45e54728e7 # Parent e75024e41f811f243219ff34f8881e8484a57917 plugin sec aesgcm: new plugin handling `aesgcm:` scheme for e2e encrypted media sharing: The scheme is register with download meta plugin, and it is activated with HTTP Upload (XEP-0363) when `encryption` is used in `options` with the value of C.ENC_AES_GCM. This is also known as `OMEMO Media Sharing` even if this method is not directly linked to OMEMO and can be used independently. diff -r e75024e41f81 -r 4f8bdf50593f sat/core/constants.py --- a/sat/core/constants.py Fri Dec 20 12:28:04 2019 +0100 +++ b/sat/core/constants.py Fri Dec 20 12:28:04 2019 +0100 @@ -132,6 +132,9 @@ MESS_KEY_ENCRYPTED = "encrypted" MESS_KEY_TRUSTED = "trusted" + # File encryption algorithms + ENC_AES_GCM = "AES-GCM" + ## Chat ## CHAT_ONE2ONE = "one2one" CHAT_GROUP = "group" diff -r e75024e41f81 -r 4f8bdf50593f sat/plugins/plugin_sec_aesgcm.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_sec_aesgcm.py Fri Dec 20 12:28:04 2019 +0100 @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 + +# SAT plugin for handling AES-GCM file encryption +# Copyright (C) 2009-2019 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 . + +from textwrap import dedent +from functools import partial +import secrets +from cryptography.hazmat.primitives import ciphers +from cryptography.hazmat.primitives.ciphers import modes +from cryptography.hazmat import backends +from cryptography.exceptions import AlreadyFinalized +from urllib import parse +import treq +from sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core import exceptions +from sat.tools import stream +from sat.core.log import getLogger + +log = getLogger(__name__) + +PLUGIN_INFO = { + C.PI_NAME: "AES-GCM", + C.PI_IMPORT_NAME: "AES-GCM", + C.PI_TYPE: "SEC", + C.PI_PROTOCOLS: ["OMEMO Media sharing"], + C.PI_DEPENDENCIES: ["XEP-0363", "XEP-0384", "DOWNLOAD"], + C.PI_MAIN: "AESGCM", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: dedent(_("""\ + Implementation of AES-GCM scheme, a way to encrypt files (not official XMPP standard). + See https://xmpp.org/extensions/inbox/omemo-media-sharing.html for details + """)), +} + + +class AESGCM(object): + + def __init__(self, host): + self.host = host + log.info(_("AESGCM plugin initialization")) + host.plugins["DOWNLOAD"].registerScheme( + "aesgcm", self.download + ) + host.trigger.add("XEP-0363_upload_size", self._uploadSizeTrigger) + host.trigger.add("XEP-0363_upload", self._uploadTrigger) + + async def download(self, client, uri_parsed, dest_path, options): + fragment = bytes.fromhex(uri_parsed.fragment) + + # legacy method use 16 bits IV, but OMEMO media sharing published spec indicates + # which is 12 bits IV (AES-GCM spec recommandation), so we have to determine + # which size has been used. + if len(fragment) == 48: + iv_size = 16 + elif len(fragment) == 44: + iv_size = 12 + else: + raise ValueError( + f"Invalid URL fragment, can't decrypt file at {uri_parsed.get_url()}") + + iv, key = fragment[:iv_size], fragment[iv_size:] + + decryptor = ciphers.Cipher( + ciphers.algorithms.AES(key), + modes.GCM(iv), + backend=backends.default_backend(), + ).decryptor() + + download_url = parse.urlunparse( + ('https', uri_parsed.netloc, uri_parsed.path, '', '', '')) + + head_data = await treq.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, + ) + + progress_id = file_obj.uid + + resp = await treq.get(download_url, unbuffered=True) + d = treq.collect(resp, partial( + self.onDataDownload, + client=client, + file_obj=file_obj, + decryptor=decryptor)) + return progress_id, d + + def onDataDownload(self, data, client, file_obj, decryptor): + if file_obj.tell() + len(data) > file_obj.size: + # 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() + 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 _uploadSizeTrigger(self, client, options, file_path, size, size_adjust): + if options.get('encryption') != C.ENC_AES_GCM: + return True + # the tag is appended to the file + size_adjust.append(16) + return True + + def _encrypt(self, data, encryptor): + if data: + return encryptor.update(data) + else: + try: + # end of file is reached, me must finalize + ret = encryptor.finalize() + tag = encryptor.tag + return ret + tag + except AlreadyFinalized: + # as we have already finalized, we can now send EOF + return b'' + + def _uploadTrigger(self, client, options, sat_file, file_producer, slot): + if options.get('encryption') != C.ENC_AES_GCM: + return True + log.debug("encrypting file with AES-GCM") + # specification talks about 12 bytes IV, but in practice and for legacy reasons + # 16 bytes are used by most clients (and also in the specification example). + # It seems that some clients don't handle 12 bytes IV (apparently, + # that's the case for ChatSecure). + # So we have to follow the de-facto standard and use 16 bytes to be sure + # to be compatible with a maximum of clients. + iv = secrets.token_bytes(16) + key = secrets.token_bytes(32) + fragment = f'{iv.hex()}{key.hex()}' + ori_url = parse.urlparse(slot.get) + # we change the get URL with the one with aesgcm scheme and containing the + # encoded key + iv + slot.get = parse.urlunparse(['aesgcm', *ori_url[1:5], fragment]) + + # 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 has + # 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}") + + # with data_cb we encrypt the file on the fly + sat_file.data_cb = partial(self._encrypt, encryptor=encryptor) + return True diff -r e75024e41f81 -r 4f8bdf50593f setup.py --- a/setup.py Fri Dec 20 12:28:04 2019 +0100 +++ b/setup.py Fri Dec 20 12:28:04 2019 +0100 @@ -36,6 +36,7 @@ 'pillow', 'progressbar', 'pycrypto >= 2.6.1', + 'cryptography', 'pygments', 'pygobject', 'PyOpenSSL',