Mercurial > libervia-backend
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 |