Package Gnumed :: Package pycommon :: Module gmCrypto
[frames] | no frames]

Source Code for Module Gnumed.pycommon.gmCrypto

  1  # -*- coding: utf-8 -*- 
  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  # std libs 
 16  import sys 
 17  import os 
 18  import logging 
 19  import tempfile 
 20   
 21   
 22  # GNUmed libs 
 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  # archiving methods 
 35  #--------------------------------------------------------------------------- 
36 -def create_encrypted_zip_archive_from_dir(source_dir, comment=None, overwrite=True, passphrase=None, verbose=False):
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 # remove existing archives so they don't get *updated* rather than newly created 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 # 7z does not support ZIP comments so create a text file holding the comment 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 # create inner (data) archive: uncompressed, unencrypted, similar to a tar archive 114 args = [ 115 binary, 116 'a', # create archive 117 '-sas', # be smart about archive name extension 118 '-bd', # no progress indicator 119 '-mx0', # no compression (only store files) 120 '-mcu=on', # UTF8 filenames 121 '-l', # store content of links, not links 122 '-scsUTF-8', # console charset 123 '-tzip' # force ZIP format 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 # create "decompress instructions" file 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 # create outer (wrapper) archive: compressed, encrypted 144 args = [ 145 binary, 146 'a', # create archive 147 '-sas', # be smart about archive name extension 148 '-bd', # no progress indicator 149 '-mx9', # best available zip compression ratio 150 '-mcu=on', # UTF8 filenames 151 '-l', # store content of links, not links 152 '-scsUTF-8', # console charset 153 '-tzip', # force ZIP format 154 '-mem=AES256', # force useful encryption 155 '-p%s' % passphrase # set 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 #---------------------------------------------------------------------------
171 -def create_zip_archive_from_dir(source_dir, archive_name=None, comment=None, overwrite=True, verbose=False):
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 # do not assume we can write to "sourcedir/../" 188 archive_path = gmTools.gmPaths().tmp_dir 189 # but do take archive name from source_dir 190 tmp, archive_fname = os.path.split(source_dir.rstrip(os.sep) + '.zip') 191 archive_name = os.path.join(archive_path, archive_fname) 192 # remove any existing archives so they don't get *updated* 193 # rather than newly created 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 # 7z does not support ZIP comments so create 199 # a text file holding the comment ... 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 # compress 210 args = [ 211 binary, 212 'a', # create archive 213 '-sas', # be smart about archive name extension 214 '-bd', # no progress indicator 215 '-mx9', # best available zip compression ratio 216 '-mcu=on', # UTF8 filenames 217 '-l', # store content of links, not links 218 '-scsUTF-8', # console charset 219 '-tzip' # force ZIP format 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 # file decryption methods 240 #---------------------------------------------------------------------------
241 -def gpg_decrypt_file(filename=None, verbose=False, target_ext=None):
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 ##'--use-embedded-filename' # not all encrypted files carry a filename 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 ##'--debug-all', # will log passphrase 275 ##'--debug, 'ipc', # will log passphrase 276 ##'--debug-level', 'guru', # will log passphrase 277 ##'--debug-level', '9', # will log passphrase 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 # file encryption methods 287 #---------------------------------------------------------------------------
288 -def gpg_encrypt_file_symmetric(filename=None, comment=None, verbose=False, passphrase=None, remove_unencrypted=False):
289 290 #add short decr instr to comment 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 ##'--debug-all', # will log passphrase 322 ##'--debug, 'ipc', # will log passphrase 323 ##'--debug-level', 'guru', # will log passphrase 324 ##'--debug-level', '9', # will log passphrase 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 #add 7z/winzip url to comment.txt 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 # remove non-pdf file 457 gmTools.remove_file(filename) 458 filename = pdf_fname 459 460 # try PDF-inherent AES 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 # try 7z based AES 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 # try GPG based AES 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 # cannot do asymmetric 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 # asymmetric not implemented yet 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 # file anonymization methods 551 #---------------------------------------------------------------------------
552 -def anonymize_file(filename):
553 assert (filename is not None), '<filename> must not be None'
554 555 556 557 558 #=========================================================================== 559 # main 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 # for testing: 570 logging.basicConfig(level = logging.DEBUG) 571 from Gnumed.pycommon import gmI18N 572 gmI18N.activate_locale() 573 gmI18N.install_domain() 574 575 #-----------------------------------------------------------------------
576 - def test_gpg_decrypt():
577 print(gpg_decrypt_file(filename = sys.argv[2], verbose = True))
578 579 #-----------------------------------------------------------------------
580 - def test_gpg_encrypt_symmetric():
581 print(gpg_encrypt_file_symmetric(filename = sys.argv[2], passphrase = sys.argv[3], verbose = True, comment = 'GNUmed testing'))
582 583 #-----------------------------------------------------------------------
584 - def test_aes_encrypt():
585 print(aes_encrypt_file(filename = sys.argv[2], passphrase = sys.argv[3], comment = sys.argv[4], verbose = True))
586 587 #-----------------------------------------------------------------------
588 - def test_encrypt_pdf():
589 print(encrypt_pdf(filename = sys.argv[2], passphrase = sys.argv[3], verbose = True))
590 591 #-----------------------------------------------------------------------
592 - def test_encrypt_file():
593 print(encrypt_file(filename = sys.argv[2], passphrase = sys.argv[3], verbose = True, convert2pdf = True))
594 595 #-----------------------------------------------------------------------
596 - def test_zip_archive_from_dir():
597 print(create_zip_archive_from_dir ( 598 sys.argv[2], 599 #archive_name=None, 600 comment = 'GNUmed test archive', 601 overwrite = True, 602 verbose = True 603 ))
604 605 #-----------------------------------------------------------------------
606 - def test_encrypted_zip_archive_from_dir():
607 print(create_encrypted_zip_archive_from_dir ( 608 sys.argv[2], 609 comment = 'GNUmed test archive', 610 overwrite = True, 611 passphrase = sys.argv[3], 612 verbose = True 613 ))
614 615 #----------------------------------------------------------------------- 616 # encryption 617 #test_aes_encrypt() 618 test_encrypt_pdf() 619 #test_gpg_encrypt_symmetric() 620 #test_encrypt_file() 621 622 # decryption 623 #test_gpg_decrypt() 624 625 #test_zip_archive_from_dir() 626 #test_encrypted_zip_archive_from_dir() 627