1
2
3 __doc__ = """GNUmed crypto tools.
4
5 First and only rule:
6
7 DO NOT REIMPLEMENT ENCRYPTION
8
9 Use existing tools.
10 """
11
12 __author__ = "K. Hilbert <Karsten.Hilbert@gmx.net>"
13 __license__ = "GPL v2 or later (details at http://www.gnu.org)"
14
15
16 import sys
17 import os
18 import logging
19
20
21
22 if __name__ == '__main__':
23 sys.path.insert(0, '../../')
24 from Gnumed.pycommon import gmLog2
25 from Gnumed.pycommon import gmShellAPI
26 from Gnumed.pycommon import gmTools
27
28
29 _log = logging.getLogger('gm.encryption')
30
31
32
33
35 """Use 7z to create an encrypted ZIP archive of a directory.
36
37 <source_dir> will be included into the archive
38 <comment> included as a file containing the comment
39 <overwrite> remove existing archive before creation, avoiding
40 *updating* of those, and thereby including unintended data
41 <passphrase> minimum length of 5
42
43 The resulting zip archive will always be named
44 "datawrapper.zip" for confidentiality reasons. If callers
45 want another name they will have to shutil.move() the zip
46 file themselves. This archive will be compressed and
47 AES256 encrypted with the given passphrase. Therefore,
48 the result will not decrypt with earlier versions of
49 unzip software. On Windows, 7z oder WinZip are needed.
50
51 The zip format does not support header encryption thereby
52 allowing attackers to gain knowledge of patient details
53 by observing the names of files and directories inside
54 the encrypted archive.
55
56 To reduce that attack surface, GNUmed will create
57 _another_ zip archive inside "datawrapper.zip", which
58 eventually wraps up the patient data as "data.zip". That
59 archive is not compressed and not encrypted, and can thus
60 be unpacked with any old unzipper.
61
62 Note that GNUmed does NOT remember the passphrase for
63 you. You will have to take care of that yourself, and
64 possibly also safely hand over the passphrase to any
65 receivers of the zip archive.
66 """
67 if len(passphrase) < 5:
68 _log.error('<passphrase> must be at least 5 characters/signs/digits')
69 return None
70 gmLog2.add_word2hide(passphrase)
71
72 source_dir = os.path.abspath(source_dir)
73 if not os.path.isdir(source_dir):
74 _log.error('<source_dir> does not exist or is not a directory: %s', source_dir)
75 return False
76
77 for cmd in ['7z', '7z.exe']:
78 found, binary = gmShellAPI.detect_external_binary(binary = cmd)
79 if found:
80 break
81 if not found:
82 _log.warning('no 7z binary found')
83 return None
84
85 sandbox_dir = gmTools.mk_sandbox_dir()
86 archive_path_inner = os.path.join(sandbox_dir, 'data')
87 if not gmTools.mkdir(archive_path_inner):
88 _log.error('cannot create scratch space for inner achive: %s', archive_path_inner)
89 archive_fname_inner = 'data.zip'
90 archive_name_inner = os.path.join(archive_path_inner, archive_fname_inner)
91 archive_path_outer = gmTools.gmPaths().tmp_dir
92 archive_fname_outer = 'datawrapper.zip'
93 archive_name_outer = os.path.join(archive_path_outer, archive_fname_outer)
94
95 if overwrite:
96 if not gmTools.remove_file(archive_name_inner, force = True):
97 _log.error('cannot remove existing archive [%s]', archive_name_inner)
98 return False
99
100 if not gmTools.remove_file(archive_name_outer, force = True):
101 _log.error('cannot remove existing archive [%s]', archive_name_outer)
102 return False
103
104
105 if comment is not None:
106 tmp, fname = os.path.split(source_dir.rstrip(os.sep))
107 comment_filename = os.path.join(sandbox_dir, '000-%s-comment.txt' % fname)
108 with open(comment_filename, mode = 'wt', encoding = 'utf8', errors = 'replace') as comment_file:
109 comment_file.write(comment)
110
111
112 args = [
113 binary,
114 'a',
115 '-sas',
116 '-bd',
117 '-mx0',
118 '-mcu=on',
119 '-l',
120 '-scsUTF-8',
121 '-tzip'
122 ]
123 if verbose:
124 args.append('-bb3')
125 args.append('-bt')
126 else:
127 args.append('-bb1')
128 args.append(archive_name_inner)
129 args.append(source_dir)
130 if comment is not None:
131 args.append(comment_filename)
132 success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, encoding = 'utf8', verbose = verbose)
133 if not success:
134 _log.error('cannot create inner archive')
135 return None
136
137
138 instructions_filename = os.path.join(archive_path_inner, '000_Windows-open_with-WinZip-or-7z_tools')
139 open(instructions_filename, mode = 'wt').close()
140
141
142 args = [
143 binary,
144 'a',
145 '-sas',
146 '-bd',
147 '-mx9',
148 '-mcu=on',
149 '-l',
150 '-scsUTF-8',
151 '-tzip',
152 '-mem=AES256',
153 '-p%s' % passphrase
154 ]
155 if verbose:
156 args.append('-bb3')
157 args.append('-bt')
158 else:
159 args.append('-bb1')
160 args.append(archive_name_outer)
161 args.append(archive_path_inner)
162 success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, encoding = 'utf8', verbose = verbose)
163 if success:
164 return archive_name_outer
165 _log.error('cannot create outer archive')
166 return None
167
168
170
171 source_dir = os.path.abspath(source_dir)
172 if not os.path.isdir(source_dir):
173 _log.error('<source_dir> does not exist or is not a directory: %s', source_dir)
174 return False
175
176 for cmd in ['7z', '7z.exe']:
177 found, binary = gmShellAPI.detect_external_binary(binary = cmd)
178 if found:
179 break
180 if not found:
181 _log.warning('no 7z binary found')
182 return None
183
184 if archive_name is None:
185
186 archive_path = gmTools.gmPaths().tmp_dir
187
188 tmp, archive_fname = os.path.split(source_dir.rstrip(os.sep) + '.zip')
189 archive_name = os.path.join(archive_path, archive_fname)
190
191
192 if comment is not None:
193 tmp, fname = os.path.split(os.path.abspath(archive_name))
194 comment_filename = gmTools.get_unique_filename (
195 prefix = '%s.' % fname,
196 suffix = '.comment.txt'
197 )
198 with open(comment_filename, mode = 'wt', encoding = 'utf8', errors = 'replace') as comment_file:
199 comment_file.write(comment)
200
201
202 if overwrite:
203 if not gmTools.remove_file(archive_name, force = True):
204 _log.error('cannot remove existing archive [%s]', archive_name)
205 return False
206
207
208 args = [
209 binary,
210 'a',
211 '-sas',
212 '-bd',
213 '-mx9',
214 '-mcu=on',
215 '-l',
216 '-scsUTF-8',
217 '-tzip'
218 ]
219 if verbose:
220 args.append('-bb3')
221 args.append('-bt')
222 else:
223 args.append('-bb1')
224 args.append(archive_name)
225 args.append(source_dir)
226 if comment is not None:
227 args.append(comment_filename)
228 success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, encoding = 'utf8', verbose = verbose)
229 if comment is not None:
230 gmTools.remove_file(comment_filename)
231 if success:
232 return archive_name
233
234 return None
235
236
237
238
239 -def gpg_decrypt_file(filename=None, passphrase=None, verbose=False, target_ext=None):
240 assert (filename is not None), '<filename> must not be None'
241
242 _log.debug('attempting GPG decryption')
243 for cmd in ['gpg2', 'gpg', 'gpg2.exe', 'gpg.exe']:
244 found, binary = gmShellAPI.detect_external_binary(binary = cmd)
245 if found:
246 break
247 if not found:
248 _log.warning('no gpg binary found')
249 return None
250
251 basename = os.path.splitext(filename)[0]
252 filename_decrypted = gmTools.get_unique_filename(prefix = '%s-decrypted-' % basename, suffix = target_ext)
253 args = [
254 binary,
255 '--utf8-strings',
256 '--display-charset', 'utf-8',
257 '--batch',
258 '--no-greeting',
259 '--enable-progress-filter',
260 '--decrypt',
261 '--output', filename_decrypted
262
263 ]
264 if verbose:
265 args.extend ([
266 '--verbose', '--verbose',
267 '--debug-level', '8',
268 '--debug', 'packet,mpi,crypto,filter,iobuf,memory,cache,memstat,trust,hashing,clock,lookup,extprog'
269
270
271
272
273 ])
274 args.append(filename)
275 success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, verbose = verbose, encoding = 'utf-8')
276 if success:
277 return filename_decrypted
278 return None
279
280
281
282
284
285
286
287 assert (filename is not None), '<filename> must not be None'
288
289 _log.debug('attempting symmetric GPG encryption')
290 for cmd in ['gpg2', 'gpg', 'gpg2.exe', 'gpg.exe']:
291 found, binary = gmShellAPI.detect_external_binary(binary = cmd)
292 if found:
293 break
294 if not found:
295 _log.warning('no gpg binary found')
296 return None
297 filename_encrypted = filename + '.asc'
298 args = [
299 binary,
300 '--utf8-strings',
301 '--display-charset', 'utf-8',
302 '--batch',
303 '--no-greeting',
304 '--enable-progress-filter',
305 '--symmetric',
306 '--cipher-algo', 'AES256',
307 '--armor',
308 '--output', filename_encrypted
309 ]
310 if comment is not None:
311 args.extend(['--comment', comment])
312 if verbose:
313 args.extend ([
314 '--verbose', '--verbose',
315 '--debug-level', '8',
316 '--debug', 'packet,mpi,crypto,filter,iobuf,memory,cache,memstat,trust,hashing,clock,lookup,extprog',
317
318
319
320
321 ])
322 args.append(filename)
323 success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, verbose = verbose, encoding = 'utf-8')
324 if success:
325 return filename_encrypted
326 return None
327
328
329 -def aes_encrypt_file(filename=None, passphrase=None, comment=None, verbose=False):
330 assert (filename is not None), '<filename> must not be None'
331 assert (passphrase is not None), '<passphrase> must not be None'
332
333 if len(passphrase) < 5:
334 _log.error('<passphrase> must be at least 5 characters/signs/digits')
335 return None
336 gmLog2.add_word2hide(passphrase)
337
338
339 _log.debug('attempting 7z AES encryption')
340 for cmd in ['7z', '7z.exe']:
341 found, binary = gmShellAPI.detect_external_binary(binary = cmd)
342 if found:
343 break
344 if not found:
345 _log.warning('no 7z binary found, trying gpg')
346 return None
347
348 if comment is not None:
349 archive_path, archive_name = os.path.split(os.path.abspath(filename))
350 comment_filename = gmTools.get_unique_filename (
351 prefix = '%s.7z.comment-' % archive_name,
352 tmp_dir = archive_path,
353 suffix = '.txt'
354 )
355 with open(comment_filename, mode = 'wt', encoding = 'utf8', errors = 'replace') as comment_file:
356 comment_file.write(comment)
357 else:
358 comment_filename = ''
359 filename_encrypted = '%s.7z' % filename
360 args = [binary, 'a', '-bb3', '-mx0', "-p%s" % passphrase, filename_encrypted, filename, comment_filename]
361 encrypted, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, encoding = 'utf8', verbose = verbose)
362 gmTools.remove_file(comment_filename)
363 if encrypted:
364 return filename_encrypted
365 return None
366
367
368 -def encrypt_pdf(filename=None, passphrase=None, verbose=False):
369 assert (filename is not None), '<filename> must not be None'
370 assert (passphrase is not None), '<passphrase> must not be None'
371
372 if len(passphrase) < 5:
373 _log.error('<passphrase> must be at least 5 characters/signs/digits')
374 return None
375 gmLog2.add_word2hide(passphrase)
376
377 _log.debug('attempting PDF encryption')
378 for cmd in ['qpdf', 'qpdf.exe']:
379 found, binary = gmShellAPI.detect_external_binary(binary = cmd)
380 if found:
381 break
382 if not found:
383 _log.warning('no qpdf binary found')
384 return None
385
386 filename_encrypted = '%s.encrypted.pdf' % os.path.splitext(filename)[0]
387 args = [
388 binary,
389 '--verbose',
390 '--encrypt', passphrase, '', '128',
391 '--print=full', '--modify=none', '--extract=n',
392 '--use-aes=y',
393 '--',
394 filename,
395 filename_encrypted
396 ]
397 success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, encoding = 'utf8', verbose = verbose)
398 if success:
399 return filename_encrypted
400 return None
401
402
416
417
418 -def encrypt_file(filename=None, receiver_key_ids=None, passphrase=None, comment=None, verbose=False):
419 assert (filename is not None), '<filename> must not be None'
420
421
422 if receiver_key_ids is None:
423 _log.debug('no receiver key IDs: cannot try asymmetric encryption')
424 return encrypt_file_symmetric(filename = filename, passphrase = passphrase, comment = comment, verbose = verbose)
425
426
427 return None
428
429
430
431
432 if __name__ == '__main__':
433
434 if len(sys.argv) < 2:
435 sys.exit()
436
437 if sys.argv[1] != 'test':
438 sys.exit()
439
440
441 logging.basicConfig(level = logging.DEBUG)
442 from Gnumed.pycommon import gmI18N
443 gmI18N.activate_locale()
444 gmI18N.install_domain()
445
446
449
450
453
454
457
458
461
462
465
466
468 print(create_zip_archive_from_dir (
469 sys.argv[2],
470
471 comment = 'GNUmed test archive',
472 overwrite = True,
473 verbose = True
474 ))
475
476
485
486
487
488
489
490
491
492
493
494
495
496
497 test_encrypted_zip_archive_from_dir()
498