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 import tempfile
20
21
22
23 if __name__ == '__main__':
24 sys.path.insert(0, '../../')
25 from Gnumed.pycommon import gmLog2
26 from Gnumed.pycommon import gmShellAPI
27 from Gnumed.pycommon import gmTools
28
29
30 _log = logging.getLogger('gm.encryption')
31
32
33
34
36 """Use 7z to create an encrypted ZIP archive of a directory.
37
38 <source_dir> will be included into the archive
39 <comment> included as a file containing the comment
40 <overwrite> remove existing archive before creation, avoiding
41 *updating* of those, and thereby including unintended data
42 <passphrase> minimum length of 5
43
44 The resulting zip archive will always be named
45 "datawrapper.zip" for confidentiality reasons. If callers
46 want another name they will have to shutil.move() the zip
47 file themselves. This archive will be compressed and
48 AES256 encrypted with the given passphrase. Therefore,
49 the result will not decrypt with earlier versions of
50 unzip software. On Windows, 7z oder WinZip are needed.
51
52 The zip format does not support header encryption thereby
53 allowing attackers to gain knowledge of patient details
54 by observing the names of files and directories inside
55 the encrypted archive.
56
57 To reduce that attack surface, GNUmed will create
58 _another_ zip archive inside "datawrapper.zip", which
59 eventually wraps up the patient data as "data.zip". That
60 archive is not compressed and not encrypted, and can thus
61 be unpacked with any old unzipper.
62
63 Note that GNUmed does NOT remember the passphrase for
64 you. You will have to take care of that yourself, and
65 possibly also safely hand over the passphrase to any
66 receivers of the zip archive.
67 """
68 if len(passphrase) < 5:
69 _log.error('<passphrase> must be at least 5 characters/signs/digits')
70 return None
71 gmLog2.add_word2hide(passphrase)
72
73 source_dir = os.path.abspath(source_dir)
74 if not os.path.isdir(source_dir):
75 _log.error('<source_dir> does not exist or is not a directory: %s', source_dir)
76 return False
77
78 for cmd in ['7z', '7z.exe']:
79 found, binary = gmShellAPI.detect_external_binary(binary = cmd)
80 if found:
81 break
82 if not found:
83 _log.warning('no 7z binary found')
84 return None
85
86 sandbox_dir = gmTools.mk_sandbox_dir()
87 archive_path_inner = os.path.join(sandbox_dir, 'data')
88 if not gmTools.mkdir(archive_path_inner):
89 _log.error('cannot create scratch space for inner achive: %s', archive_path_inner)
90 archive_fname_inner = 'data.zip'
91 archive_name_inner = os.path.join(archive_path_inner, archive_fname_inner)
92 archive_path_outer = gmTools.gmPaths().tmp_dir
93 archive_fname_outer = 'datawrapper.zip'
94 archive_name_outer = os.path.join(archive_path_outer, archive_fname_outer)
95
96 if overwrite:
97 if not gmTools.remove_file(archive_name_inner, force = True):
98 _log.error('cannot remove existing archive [%s]', archive_name_inner)
99 return False
100
101 if not gmTools.remove_file(archive_name_outer, force = True):
102 _log.error('cannot remove existing archive [%s]', archive_name_outer)
103 return False
104
105
106 if comment is not None:
107 tmp, fname = os.path.split(source_dir.rstrip(os.sep))
108 comment_filename = os.path.join(sandbox_dir, '000-%s-comment.txt' % fname)
109 with open(comment_filename, mode = 'wt', encoding = 'utf8', errors = 'replace') as comment_file:
110 comment_file.write(comment)
111
112
113 args = [
114 binary,
115 'a',
116 '-sas',
117 '-bd',
118 '-mx0',
119 '-mcu=on',
120 '-l',
121 '-scsUTF-8',
122 '-tzip'
123 ]
124 if verbose:
125 args.append('-bb3')
126 args.append('-bt')
127 else:
128 args.append('-bb1')
129 args.append(archive_name_inner)
130 args.append(source_dir)
131 if comment is not None:
132 args.append(comment_filename)
133 success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, encoding = 'utf8', verbose = verbose)
134 if not success:
135 _log.error('cannot create inner archive')
136 return None
137
138
139 instructions_filename = os.path.join(archive_path_inner, '000-on_Windows-open_with-WinZip_or_7z_tools')
140 open(instructions_filename, mode = 'wt').close()
141
142
143 args = [
144 binary,
145 'a',
146 '-sas',
147 '-bd',
148 '-mx9',
149 '-mcu=on',
150 '-l',
151 '-scsUTF-8',
152 '-tzip',
153 '-mem=AES256',
154 '-p%s' % passphrase
155 ]
156 if verbose:
157 args.append('-bb3')
158 args.append('-bt')
159 else:
160 args.append('-bb1')
161 args.append(archive_name_outer)
162 args.append(archive_path_inner)
163 success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, encoding = 'utf8', verbose = verbose)
164 if success:
165 return archive_name_outer
166 _log.error('cannot create outer archive')
167 return None
168
169
171
172 source_dir = os.path.abspath(source_dir)
173 if not os.path.isdir(source_dir):
174 _log.error('<source_dir> does not exist or is not a directory: %s', source_dir)
175 return False
176
177 for cmd in ['7z', '7z.exe']:
178 found, binary = gmShellAPI.detect_external_binary(binary = cmd)
179 if found:
180 break
181 if not found:
182 _log.warning('no 7z binary found')
183 return None
184
185 if archive_name is None:
186
187 archive_path = gmTools.gmPaths().tmp_dir
188
189 tmp, archive_fname = os.path.split(source_dir.rstrip(os.sep) + '.zip')
190 archive_name = os.path.join(archive_path, archive_fname)
191
192
193 if overwrite:
194 if not gmTools.remove_file(archive_name, force = True):
195 _log.error('cannot remove existing archive [%s]', archive_name)
196 return False
197
198
199 if comment is not None:
200 comment_filename = os.path.abspath(archive_name) + '.comment.txt'
201 if gmTools.remove_file(comment_filename, force = True):
202 with open(comment_filename, mode = 'wt', encoding = 'utf8', errors = 'replace') as comment_file:
203 comment_file.write(comment)
204 else:
205 _log.error('cannot remove existing archive comment file [%s]', comment_filename)
206 comment = None
207
208
209 args = [
210 binary,
211 'a',
212 '-sas',
213 '-bd',
214 '-mx9',
215 '-mcu=on',
216 '-l',
217 '-scsUTF-8',
218 '-tzip'
219 ]
220 if verbose:
221 args.append('-bb3')
222 args.append('-bt')
223 else:
224 args.append('-bb1')
225 args.append(archive_name)
226 args.append(source_dir)
227 if comment is not None:
228 args.append(comment_filename)
229 success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, encoding = 'utf8', verbose = verbose)
230 if comment is not None:
231 gmTools.remove_file(comment_filename)
232 if success:
233 return archive_name
234
235 return None
236
237
238
239
240 -def gpg_decrypt_file(filename=None, passphrase=None, verbose=False, target_ext=None):
241 assert (filename is not None), '<filename> must not be None'
242
243 _log.debug('attempting GPG decryption')
244 for cmd in ['gpg2', 'gpg', 'gpg2.exe', 'gpg.exe']:
245 found, binary = gmShellAPI.detect_external_binary(binary = cmd)
246 if found:
247 break
248 if not found:
249 _log.warning('no gpg binary found')
250 return None
251
252 basename = os.path.splitext(filename)[0]
253 filename_decrypted = gmTools.get_unique_filename(prefix = '%s-decrypted-' % basename, suffix = target_ext)
254 args = [
255 binary,
256 '--utf8-strings',
257 '--display-charset', 'utf-8',
258 '--batch',
259 '--no-greeting',
260 '--enable-progress-filter',
261 '--decrypt',
262 '--output', filename_decrypted
263
264 ]
265 if verbose:
266 args.extend ([
267 '--verbose', '--verbose',
268 '--debug-level', '8',
269 '--debug', 'packet,mpi,crypto,filter,iobuf,memory,cache,memstat,trust,hashing,clock,lookup,extprog'
270
271
272
273
274 ])
275 args.append(filename)
276 success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, verbose = verbose, encoding = 'utf-8')
277 if success:
278 return filename_decrypted
279 return None
280
281
282
283
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 pwd_fname = None
323 if passphrase is not None:
324 pwd_file = tempfile.NamedTemporaryFile(mode = 'w+t', encoding = 'utf8', delete = False)
325 pwd_fname = pwd_file.name
326 args.extend ([
327 '--pinentry-mode', 'loopback',
328 '--passphrase-file', pwd_fname
329 ])
330 pwd_file.write(passphrase)
331 pwd_file.close()
332 args.append(filename)
333 try:
334 success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, verbose = verbose, encoding = 'utf-8')
335 finally:
336 if pwd_fname is not None:
337 os.remove(pwd_fname)
338 if not success:
339 return None
340 if not remove_unencrypted:
341 return filename_encrypted
342 if gmTools.remove_file(filename):
343 return filename_encrypted
344 gmTools.remove_file(filename_encrypted)
345 return None
346
347
348 -def aes_encrypt_file(filename=None, passphrase=None, comment=None, verbose=False, remove_unencrypted=False):
349 assert (filename is not None), '<filename> must not be None'
350 assert (passphrase is not None), '<passphrase> must not be None'
351
352 if len(passphrase) < 5:
353 _log.error('<passphrase> must be at least 5 characters/signs/digits')
354 return None
355 gmLog2.add_word2hide(passphrase)
356
357
358 _log.debug('attempting 7z AES encryption')
359 for cmd in ['7z', '7z.exe']:
360 found, binary = gmShellAPI.detect_external_binary(binary = cmd)
361 if found:
362 break
363 if not found:
364 _log.warning('no 7z binary found, trying gpg')
365 return None
366
367 if comment is not None:
368 archive_path, archive_name = os.path.split(os.path.abspath(filename))
369 comment_filename = gmTools.get_unique_filename (
370 prefix = '%s.7z.comment-' % archive_name,
371 tmp_dir = archive_path,
372 suffix = '.txt'
373 )
374 with open(comment_filename, mode = 'wt', encoding = 'utf8', errors = 'replace') as comment_file:
375 comment_file.write(comment)
376 else:
377 comment_filename = ''
378 filename_encrypted = '%s.7z' % filename
379 args = [binary, 'a', '-bb3', '-mx0', "-p%s" % passphrase, filename_encrypted, filename, comment_filename]
380 encrypted, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, encoding = 'utf8', verbose = verbose)
381 gmTools.remove_file(comment_filename)
382 if not encrypted:
383 return None
384 if not remove_unencrypted:
385 return filename_encrypted
386 if gmTools.remove_file(filename):
387 return filename_encrypted
388 gmTools.remove_file(filename_encrypted)
389 return None
390
391
392 -def encrypt_pdf(filename=None, passphrase=None, verbose=False):
393 assert (filename is not None), '<filename> must not be None'
394 assert (passphrase is not None), '<passphrase> must not be None'
395
396 if len(passphrase) < 5:
397 _log.error('<passphrase> must be at least 5 characters/signs/digits')
398 return None
399 gmLog2.add_word2hide(passphrase)
400
401 _log.debug('attempting PDF encryption')
402 for cmd in ['qpdf', 'qpdf.exe']:
403 found, binary = gmShellAPI.detect_external_binary(binary = cmd)
404 if found:
405 break
406 if not found:
407 _log.warning('no qpdf binary found')
408 return None
409
410 filename_encrypted = '%s.encrypted.pdf' % os.path.splitext(filename)[0]
411 args = [
412 binary,
413 '--verbose',
414 '--encrypt', passphrase, '', '128',
415 '--print=full', '--modify=none', '--extract=n',
416 '--use-aes=y',
417 '--',
418 filename,
419 filename_encrypted
420 ]
421 success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, encoding = 'utf8', verbose = verbose)
422 if success:
423 return filename_encrypted
424 return None
425
426
427 -def encrypt_file_symmetric(filename=None, passphrase=None, comment=None, verbose=False, remove_unencrypted=False):
428 assert (filename is not None), '<filename> must not be None'
429
430
431 enc_filename = encrypt_pdf(filename = filename, passphrase = passphrase, verbose = verbose)
432 if enc_filename is not None:
433 return enc_filename
434
435 enc_filename = aes_encrypt_file(filename = filename, passphrase = passphrase, comment = comment, verbose = verbose, remove_unencrypted = remove_unencrypted)
436 if enc_filename is not None:
437 return enc_filename
438
439 return gpg_encrypt_file_symmetric(filename = filename, passphrase = passphrase, comment = comment, verbose = verbose, remove_unencrypted = remove_unencrypted)
440
441
442 -def encrypt_file(filename=None, receiver_key_ids=None, passphrase=None, comment=None, verbose=False, remove_unencrypted=False):
443 assert (filename is not None), '<filename> must not be None'
444
445
446 if receiver_key_ids is None:
447 _log.debug('no receiver key IDs: cannot try asymmetric encryption')
448 return encrypt_file_symmetric(filename = filename, passphrase = passphrase, comment = comment, verbose = verbose, remove_unencrypted = remove_unencrypted)
449
450
451 return None
452
453
454 -def encrypt_directory_content(directory=None, receiver_key_ids=None, passphrase=None, comment=None, verbose=False, remove_unencrypted=True):
455 assert (directory is not None), 'source <directory> must not be None'
456 _log.debug('encrypting content of [%s]', directory)
457 try:
458 items = os.listdir(directory)
459 except OSError:
460 return False
461
462 for item in items:
463 full_item = os.path.join(directory, item)
464 if os.path.isdir(full_item):
465 subdir_encrypted = encrypt_directory_content (
466 directory = full_item,
467 receiver_key_ids = receiver_key_ids,
468 passphrase = passphrase,
469 comment = comment,
470 verbose = verbose
471 )
472 if subdir_encrypted is False:
473 return False
474 continue
475
476 fname_encrypted = encrypt_file (
477 filename = full_item,
478 receiver_key_ids = receiver_key_ids,
479 passphrase = passphrase,
480 comment = comment,
481 verbose = verbose,
482 remove_unencrypted = remove_unencrypted
483 )
484 if fname_encrypted is None:
485 return False
486
487 return True
488
489
490
491
492 if __name__ == '__main__':
493
494 if len(sys.argv) < 2:
495 sys.exit()
496
497 if sys.argv[1] != 'test':
498 sys.exit()
499
500
501 logging.basicConfig(level = logging.DEBUG)
502 from Gnumed.pycommon import gmI18N
503 gmI18N.activate_locale()
504 gmI18N.install_domain()
505
506
509
510
513
514
517
518
521
522
525
526
528 print(create_zip_archive_from_dir (
529 sys.argv[2],
530
531 comment = 'GNUmed test archive',
532 overwrite = True,
533 verbose = True
534 ))
535
536
545
546
547
548
549 test_encrypt_pdf()
550
551
552
553
554
555
556
557
558