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, 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 ##'--use-embedded-filename' # not all encrypted files carry a filename 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 ##'--debug-all', # will log passphrase 272 ##'--debug, 'ipc', # will log passphrase 273 ##'--debug-level', 'guru', # will log passphrase 274 ##'--debug-level', '9', # will log passphrase 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 # file encryption methods 284 #---------------------------------------------------------------------------
285 -def gpg_encrypt_file_symmetric(filename=None, comment=None, verbose=False, passphrase=None, remove_unencrypted=False):
286 287 #add short decr instr to comment 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 ##'--debug-all', # will log passphrase 319 ##'--debug, 'ipc', # will log passphrase 320 ##'--debug-level', 'guru', # will log passphrase 321 ##'--debug-level', '9', # will log passphrase 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 #add 7z/winzip url to comment.txt 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 # remove non-pdf file 454 gmTools.remove_file(filename) 455 filename = pdf_fname 456 457 # try PDF-inherent AES 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 # try 7z based AES 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 # try GPG based AES 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 # cannot do asymmetric 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 # asymmetric not implemented yet 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 # main 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 # for testing: 558 logging.basicConfig(level = logging.DEBUG) 559 from Gnumed.pycommon import gmI18N 560 gmI18N.activate_locale() 561 gmI18N.install_domain() 562 563 #-----------------------------------------------------------------------
564 - def test_gpg_decrypt():
565 print(gpg_decrypt_file(filename = sys.argv[2], verbose = True))
566 567 #-----------------------------------------------------------------------
568 - def test_gpg_encrypt_symmetric():
569 print(gpg_encrypt_file_symmetric(filename = sys.argv[2], passphrase = sys.argv[3], verbose = True, comment = 'GNUmed testing'))
570 571 #-----------------------------------------------------------------------
572 - def test_aes_encrypt():
573 print(aes_encrypt_file(filename = sys.argv[2], passphrase = sys.argv[3], comment = sys.argv[4], verbose = True))
574 575 #-----------------------------------------------------------------------
576 - def test_encrypt_pdf():
577 print(encrypt_pdf(filename = sys.argv[2], passphrase = sys.argv[3], verbose = True))
578 579 #-----------------------------------------------------------------------
580 - def test_encrypt_file():
581 print(encrypt_file(filename = sys.argv[2], passphrase = sys.argv[3], verbose = True, convert2pdf = True))
582 583 #-----------------------------------------------------------------------
584 - def test_zip_archive_from_dir():
585 print(create_zip_archive_from_dir ( 586 sys.argv[2], 587 #archive_name=None, 588 comment = 'GNUmed test archive', 589 overwrite = True, 590 verbose = True 591 ))
592 593 #-----------------------------------------------------------------------
594 - def test_encrypted_zip_archive_from_dir():
595 print(create_encrypted_zip_archive_from_dir ( 596 sys.argv[2], 597 comment = 'GNUmed test archive', 598 overwrite = True, 599 passphrase = sys.argv[3], 600 verbose = True 601 ))
602 603 #----------------------------------------------------------------------- 604 # encryption 605 #test_aes_encrypt() 606 #test_encrypt_pdf() 607 #test_gpg_encrypt_symmetric() 608 test_encrypt_file() 609 610 # decryption 611 #test_gpg_decrypt() 612 613 #test_zip_archive_from_dir() 614 #test_encrypted_zip_archive_from_dir() 615