comparison sat/plugins/plugin_sec_aesgcm.py @ 3090:4f8bdf50593f

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.
author Goffi <goffi@goffi.org>
date Fri, 20 Dec 2019 12:28:04 +0100
parents
children 9d0df638c8b4
comparison
equal deleted inserted replaced
3089:e75024e41f81 3090:4f8bdf50593f
1 #!/usr/bin/env python3
2
3 # SAT plugin for handling AES-GCM file encryption
4 # Copyright (C) 2009-2019 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 from textwrap import dedent
20 from functools import partial
21 import secrets
22 from cryptography.hazmat.primitives import ciphers
23 from cryptography.hazmat.primitives.ciphers import modes
24 from cryptography.hazmat import backends
25 from cryptography.exceptions import AlreadyFinalized
26 from urllib import parse
27 import treq
28 from sat.core.i18n import _
29 from sat.core.constants import Const as C
30 from sat.core import exceptions
31 from sat.tools import stream
32 from sat.core.log import getLogger
33
34 log = getLogger(__name__)
35
36 PLUGIN_INFO = {
37 C.PI_NAME: "AES-GCM",
38 C.PI_IMPORT_NAME: "AES-GCM",
39 C.PI_TYPE: "SEC",
40 C.PI_PROTOCOLS: ["OMEMO Media sharing"],
41 C.PI_DEPENDENCIES: ["XEP-0363", "XEP-0384", "DOWNLOAD"],
42 C.PI_MAIN: "AESGCM",
43 C.PI_HANDLER: "no",
44 C.PI_DESCRIPTION: dedent(_("""\
45 Implementation of AES-GCM scheme, a way to encrypt files (not official XMPP standard).
46 See https://xmpp.org/extensions/inbox/omemo-media-sharing.html for details
47 """)),
48 }
49
50
51 class AESGCM(object):
52
53 def __init__(self, host):
54 self.host = host
55 log.info(_("AESGCM plugin initialization"))
56 host.plugins["DOWNLOAD"].registerScheme(
57 "aesgcm", self.download
58 )
59 host.trigger.add("XEP-0363_upload_size", self._uploadSizeTrigger)
60 host.trigger.add("XEP-0363_upload", self._uploadTrigger)
61
62 async def download(self, client, uri_parsed, dest_path, options):
63 fragment = bytes.fromhex(uri_parsed.fragment)
64
65 # legacy method use 16 bits IV, but OMEMO media sharing published spec indicates
66 # which is 12 bits IV (AES-GCM spec recommandation), so we have to determine
67 # which size has been used.
68 if len(fragment) == 48:
69 iv_size = 16
70 elif len(fragment) == 44:
71 iv_size = 12
72 else:
73 raise ValueError(
74 f"Invalid URL fragment, can't decrypt file at {uri_parsed.get_url()}")
75
76 iv, key = fragment[:iv_size], fragment[iv_size:]
77
78 decryptor = ciphers.Cipher(
79 ciphers.algorithms.AES(key),
80 modes.GCM(iv),
81 backend=backends.default_backend(),
82 ).decryptor()
83
84 download_url = parse.urlunparse(
85 ('https', uri_parsed.netloc, uri_parsed.path, '', '', ''))
86
87 head_data = await treq.head(download_url)
88 content_length = int(head_data.headers.getRawHeaders('content-length')[0])
89 # the 128 bits tag is put at the end
90 file_size = content_length - 16
91
92 file_obj = stream.SatFile(
93 self.host,
94 client,
95 dest_path,
96 mode="wb",
97 size = file_size,
98 )
99
100 progress_id = file_obj.uid
101
102 resp = await treq.get(download_url, unbuffered=True)
103 d = treq.collect(resp, partial(
104 self.onDataDownload,
105 client=client,
106 file_obj=file_obj,
107 decryptor=decryptor))
108 return progress_id, d
109
110 def onDataDownload(self, data, client, file_obj, decryptor):
111 if file_obj.tell() + len(data) > file_obj.size:
112 # we're reaching end of file with this bunch of data
113 # we may still have a last bunch if the tag is incomplete
114 bytes_left = file_obj.size - file_obj.tell()
115 if bytes_left > 0:
116 decrypted = decryptor.update(data[:bytes_left])
117 file_obj.write(decrypted)
118 tag = data[bytes_left:]
119 else:
120 tag = data
121 if len(tag) < 16:
122 # the tag is incomplete, either we'll get the rest in next data bunch
123 # or we have already the other part from last bunch of data
124 try:
125 # we store partial tag in decryptor._sat_tag
126 tag = decryptor._sat_tag + tag
127 except AttributeError:
128 # no other part, we'll get the rest at next bunch
129 decryptor.sat_tag = tag
130 else:
131 # we have the complete tag, it must be 128 bits
132 if len(tag) != 16:
133 raise ValueError(f"Invalid tag: {tag}")
134 remain = decryptor.finalize_with_tag(tag)
135 file_obj.write(remain)
136 file_obj.close()
137 else:
138 decrypted = decryptor.update(data)
139 file_obj.write(decrypted)
140
141 def _uploadSizeTrigger(self, client, options, file_path, size, size_adjust):
142 if options.get('encryption') != C.ENC_AES_GCM:
143 return True
144 # the tag is appended to the file
145 size_adjust.append(16)
146 return True
147
148 def _encrypt(self, data, encryptor):
149 if data:
150 return encryptor.update(data)
151 else:
152 try:
153 # end of file is reached, me must finalize
154 ret = encryptor.finalize()
155 tag = encryptor.tag
156 return ret + tag
157 except AlreadyFinalized:
158 # as we have already finalized, we can now send EOF
159 return b''
160
161 def _uploadTrigger(self, client, options, sat_file, file_producer, slot):
162 if options.get('encryption') != C.ENC_AES_GCM:
163 return True
164 log.debug("encrypting file with AES-GCM")
165 # specification talks about 12 bytes IV, but in practice and for legacy reasons
166 # 16 bytes are used by most clients (and also in the specification example).
167 # It seems that some clients don't handle 12 bytes IV (apparently,
168 # that's the case for ChatSecure).
169 # So we have to follow the de-facto standard and use 16 bytes to be sure
170 # to be compatible with a maximum of clients.
171 iv = secrets.token_bytes(16)
172 key = secrets.token_bytes(32)
173 fragment = f'{iv.hex()}{key.hex()}'
174 ori_url = parse.urlparse(slot.get)
175 # we change the get URL with the one with aesgcm scheme and containing the
176 # encoded key + iv
177 slot.get = parse.urlunparse(['aesgcm', *ori_url[1:5], fragment])
178
179 # encrypted data size will be bigger than original file size
180 # so we need to check with final data length to avoid a warning on close()
181 sat_file.check_size_with_read = True
182
183 # file_producer get length directly from file, and this cause trouble has
184 # we have to change the size because of encryption. So we adapt it here,
185 # else the producer would stop reading prematurely
186 file_producer.length = sat_file.size
187
188 encryptor = ciphers.Cipher(
189 ciphers.algorithms.AES(key),
190 modes.GCM(iv),
191 backend=backends.default_backend(),
192 ).encryptor()
193
194 if sat_file.data_cb is not None:
195 raise exceptions.InternalError(
196 f"data_cb was expected to be None, it is set to {sat_file.data_cb}")
197
198 # with data_cb we encrypt the file on the fly
199 sat_file.data_cb = partial(self._encrypt, encryptor=encryptor)
200 return True