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
242 """The system is expected to be set up for safely getting the
243 passphrase from the user, typically via gpg-agent.
244 """
245 assert (filename is not None), '<filename> must not be None'
246
247 _log.debug('attempting GPG decryption')
248 for cmd in ['gpg2', 'gpg', 'gpg2.exe', 'gpg.exe']:
249 found, binary = gmShellAPI.detect_external_binary(binary = cmd)
250 if found:
251 break
252 if not found:
253 _log.warning('no gpg binary found')
254 return None
255
256 basename = os.path.splitext(filename)[0]
257 filename_decrypted = gmTools.get_unique_filename(prefix = '%s-decrypted-' % basename, suffix = target_ext)
258 args = [
259 binary,
260 '--utf8-strings',
261 '--display-charset', 'utf-8',
262 '--batch',
263 '--no-greeting',
264 '--enable-progress-filter',
265 '--decrypt',
266 '--output', filename_decrypted
267
268 ]
269 if verbose:
270 args.extend ([
271 '--verbose', '--verbose',
272 '--debug-level', '8',
273 '--debug', 'packet,mpi,crypto,filter,iobuf,memory,cache,memstat,trust,hashing,clock,lookup,extprog'
274
275
276
277
278 ])
279 args.append(filename)
280 success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, verbose = verbose, encoding = 'utf-8')
281 if success:
282 return filename_decrypted
283 return None
284
285
286
287
289
290
291 assert (filename is not None), '<filename> must not be None'
292
293 _log.debug('attempting symmetric GPG encryption')
294 for cmd in ['gpg2', 'gpg', 'gpg2.exe', 'gpg.exe']:
295 found, binary = gmShellAPI.detect_external_binary(binary = cmd)
296 if found:
297 break
298 if not found:
299 _log.warning('no gpg binary found')
300 return None
301 filename_encrypted = filename + '.asc'
302 args = [
303 binary,
304 '--utf8-strings',
305 '--display-charset', 'utf-8',
306 '--batch',
307 '--no-greeting',
308 '--enable-progress-filter',
309 '--symmetric',
310 '--cipher-algo', 'AES256',
311 '--armor',
312 '--output', filename_encrypted
313 ]
314 if comment is not None:
315 args.extend(['--comment', comment])
316 if verbose:
317 args.extend ([
318 '--verbose', '--verbose',
319 '--debug-level', '8',
320 '--debug', 'packet,mpi,crypto,filter,iobuf,memory,cache,memstat,trust,hashing,clock,lookup,extprog',
321
322
323
324
325 ])
326 pwd_fname = None
327 if passphrase is not None:
328 pwd_file = tempfile.NamedTemporaryFile(mode = 'w+t', encoding = 'utf8', delete = False)
329 pwd_fname = pwd_file.name
330 args.extend ([
331 '--pinentry-mode', 'loopback',
332 '--passphrase-file', pwd_fname
333 ])
334 pwd_file.write(passphrase)
335 pwd_file.close()
336 args.append(filename)
337 try:
338 success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, verbose = verbose, encoding = 'utf-8')
339 finally:
340 if pwd_fname is not None:
341 os.remove(pwd_fname)
342 if not success:
343 return None
344 if not remove_unencrypted:
345 return filename_encrypted
346 if gmTools.remove_file(filename):
347 return filename_encrypted
348 gmTools.remove_file(filename_encrypted)
349 return None
350
351
352 -def aes_encrypt_file(filename=None, passphrase=None, comment=None, verbose=False, remove_unencrypted=False):
353 assert (filename is not None), '<filename> must not be None'
354 assert (passphrase is not None), '<passphrase> must not be None'
355
356 if len(passphrase) < 5:
357 _log.error('<passphrase> must be at least 5 characters/signs/digits')
358 return None
359 gmLog2.add_word2hide(passphrase)
360
361
362 _log.debug('attempting 7z AES encryption')
363 for cmd in ['7z', '7z.exe']:
364 found, binary = gmShellAPI.detect_external_binary(binary = cmd)
365 if found:
366 break
367 if not found:
368 _log.warning('no 7z binary found, trying gpg')
369 return None
370
371 if comment is not None:
372 archive_path, archive_name = os.path.split(os.path.abspath(filename))
373 comment_filename = gmTools.get_unique_filename (
374 prefix = '%s.7z.comment-' % archive_name,
375 tmp_dir = archive_path,
376 suffix = '.txt'
377 )
378 with open(comment_filename, mode = 'wt', encoding = 'utf8', errors = 'replace') as comment_file:
379 comment_file.write(comment)
380 else:
381 comment_filename = ''
382 filename_encrypted = '%s.7z' % filename
383 args = [binary, 'a', '-bb3', '-mx0', "-p%s" % passphrase, filename_encrypted, filename, comment_filename]
384 encrypted, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, encoding = 'utf8', verbose = verbose)
385 gmTools.remove_file(comment_filename)
386 if not encrypted:
387 return None
388 if not remove_unencrypted:
389 return filename_encrypted
390 if gmTools.remove_file(filename):
391 return filename_encrypted
392 gmTools.remove_file(filename_encrypted)
393 return None
394
395
396 -def encrypt_pdf(filename=None, passphrase=None, verbose=False, remove_unencrypted=False):
397 assert (filename is not None), '<filename> must not be None'
398 assert (passphrase is not None), '<passphrase> must not be None'
399
400 if len(passphrase) < 5:
401 _log.error('<passphrase> must be at least 5 characters/signs/digits')
402 return None
403
404 gmLog2.add_word2hide(passphrase)
405 _log.debug('attempting PDF encryption')
406 for cmd in ['qpdf', 'qpdf.exe']:
407 found, binary = gmShellAPI.detect_external_binary(binary = cmd)
408 if found:
409 break
410 if not found:
411 _log.warning('no qpdf binary found')
412 return None
413
414 filename_encrypted = '%s.encrypted.pdf' % os.path.splitext(filename)[0]
415 args = [
416 binary,
417 '--verbose',
418 '--encrypt', passphrase, '', '128',
419 '--print=full', '--modify=none', '--extract=n',
420 '--use-aes=y',
421 '--',
422 filename,
423 filename_encrypted
424 ]
425 success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, encoding = 'utf8', verbose = verbose)
426 if not success:
427 return None
428
429 if not remove_unencrypted:
430 return filename_encrypted
431
432 if gmTools.remove_file(filename):
433 return filename_encrypted
434
435 gmTools.remove_file(filename_encrypted)
436 return None
437
438
439 -def encrypt_file_symmetric(filename=None, passphrase=None, comment=None, verbose=False, remove_unencrypted=False, convert2pdf=False):
440 """Encrypt <filename> with a symmetric cipher.
441
442 <convert2pdf> - True: convert <filename> to PDF, if possible, and encrypt that.
443 """
444 assert (filename is not None), '<filename> must not be None'
445
446 if convert2pdf:
447 _log.debug('PDF encryption preferred, attempting conversion if needed')
448 pdf_fname = gmMimeLib.convert_file (
449 filename = filename,
450 target_mime = 'application/pdf',
451 target_filename = filename + '.pdf',
452 verbose = verbose
453 )
454 if pdf_fname is not None:
455 _log.debug('successfully converted to PDF')
456
457 gmTools.remove_file(filename)
458 filename = pdf_fname
459
460
461 encrypted_filename = encrypt_pdf (
462 filename = filename,
463 passphrase = passphrase,
464 verbose = verbose,
465 remove_unencrypted = remove_unencrypted
466 )
467 if encrypted_filename is not None:
468 return encrypted_filename
469
470
471 encrypted_filename = aes_encrypt_file (
472 filename = filename,
473 passphrase = passphrase,
474 comment = comment,
475 verbose = verbose,
476 remove_unencrypted = remove_unencrypted
477 )
478 if encrypted_filename is not None:
479 return encrypted_filename
480
481
482 return gpg_encrypt_file_symmetric(filename = filename, passphrase = passphrase, comment = comment, verbose = verbose, remove_unencrypted = remove_unencrypted)
483
484
485 -def encrypt_file(filename=None, receiver_key_ids=None, passphrase=None, comment=None, verbose=False, remove_unencrypted=False, convert2pdf=False):
486 """Encrypt an arbitrary file.
487
488 <remove_unencrypted>
489 True: remove unencrypted source file if encryption succeeded
490 <convert2pdf>
491 True: attempt conversion to PDF of input file before encryption
492 success: the PDF is encrypted (and the non-PDF source file is removed)
493 failure: the source file is encrypted
494 """
495 assert (filename is not None), '<filename> must not be None'
496
497
498 if receiver_key_ids is None:
499 _log.debug('no receiver key IDs: cannot try asymmetric encryption')
500 return encrypt_file_symmetric (
501 filename = filename,
502 passphrase = passphrase,
503 comment = comment,
504 verbose = verbose,
505 remove_unencrypted = remove_unencrypted,
506 convert2pdf = convert2pdf
507 )
508
509
510 return None
511
512
513 -def encrypt_directory_content(directory=None, receiver_key_ids=None, passphrase=None, comment=None, verbose=False, remove_unencrypted=True, convert2pdf=False):
514 assert (directory is not None), 'source <directory> must not be None'
515 _log.debug('encrypting content of [%s]', directory)
516 try:
517 items = os.listdir(directory)
518 except OSError:
519 return False
520
521 for item in items:
522 full_item = os.path.join(directory, item)
523 if os.path.isdir(full_item):
524 subdir_encrypted = encrypt_directory_content (
525 directory = full_item,
526 receiver_key_ids = receiver_key_ids,
527 passphrase = passphrase,
528 comment = comment,
529 verbose = verbose
530 )
531 if subdir_encrypted is False:
532 return False
533 continue
534
535 fname_encrypted = encrypt_file (
536 filename = full_item,
537 receiver_key_ids = receiver_key_ids,
538 passphrase = passphrase,
539 comment = comment,
540 verbose = verbose,
541 remove_unencrypted = remove_unencrypted,
542 convert2pdf = convert2pdf
543 )
544 if fname_encrypted is None:
545 return False
546
547 return True
548
549
550
551
553 assert (filename is not None), '<filename> must not be None'
554
555
556
557
558
559
560
561 if __name__ == '__main__':
562
563 if len(sys.argv) < 2:
564 sys.exit()
565
566 if sys.argv[1] != 'test':
567 sys.exit()
568
569
570 logging.basicConfig(level = logging.DEBUG)
571 from Gnumed.pycommon import gmI18N
572 gmI18N.activate_locale()
573 gmI18N.install_domain()
574
575
578
579
582
583
586
587
590
591
593 print(encrypt_file(filename = sys.argv[2], passphrase = sys.argv[3], verbose = True, convert2pdf = True))
594
595
597 print(create_zip_archive_from_dir (
598 sys.argv[2],
599
600 comment = 'GNUmed test archive',
601 overwrite = True,
602 verbose = True
603 ))
604
605
614
615
616
617
618 test_encrypt_pdf()
619
620
621
622
623
624
625
626
627