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