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
     29
     30  _log = logging.getLogger('gm.encryption')
     31
     32  #===========================================================================
     33  # archiving methods
     34  #---------------------------------------------------------------------------
     35 -def create_encrypted_zip_archive_from_dir(source_dir, comment=None, overwrite=True, passphrase=None, verbose=False):
     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          # remove existing archives so they don't get *updated* rather than newly created
     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          # 7z does not support ZIP comments so create a text file holding the comment
    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          # create inner (data) archive: uncompressed, unencrypted, similar to a tar archive
    113          args = [
    114                  binary,
    115                  'a',                            # create archive
    116                  '-sas',                         # be smart about archive name extension
    117                  '-bd',                          # no progress indicator
    118                  '-mx0',                         # no compression (only store files)
    119                  '-mcu=on',                      # UTF8 filenames
    120                  '-l',                           # store content of links, not links
    121                  '-scsUTF-8',            # console charset
    122                  '-tzip'                         # force ZIP format
    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          # create "decompress instructions" file
    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          # create outer (wrapper) archive: compressed, encrypted
    143          args = [
    144                  binary,
    145                  'a',                                    # create archive
    146                  '-sas',                                 # be smart about archive name extension
    147                  '-bd',                                  # no progress indicator
    148                  '-mx9',                                 # best available zip compression ratio
    149                  '-mcu=on',                              # UTF8 filenames
    150                  '-l',                                   # store content of links, not links
    151                  '-scsUTF-8',                    # console charset
    152                  '-tzip',                                # force ZIP format
    153                  '-mem=AES256',                  # force useful encryption
    154                  '-p%s' % passphrase             # set 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  #---------------------------------------------------------------------------
    170 -def create_zip_archive_from_dir(source_dir, archive_name=None, comment=None, overwrite=True, verbose=False):
    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                  # do not assume we can write to "sourcedir/../"
    187                  archive_path = gmTools.gmPaths().tmp_dir
    188                  # but do take archive name from source_dir
    189                  tmp, archive_fname = os.path.split(source_dir.rstrip(os.sep) + '.zip')
    190                  archive_name = os.path.join(archive_path, archive_fname)
    191          # remove any existing archives so they don't get *updated*
    192          # rather than newly created
    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          # 7z does not support ZIP comments so create
    198          # a text file holding the comment ...
    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          # compress
    209          args = [
    210                  binary,
    211                  'a',                            # create archive
    212                  '-sas',                         # be smart about archive name extension
    213                  '-bd',                          # no progress indicator
    214                  '-mx9',                         # best available zip compression ratio
    215                  '-mcu=on',                      # UTF8 filenames
    216                  '-l',                           # store content of links, not links
    217                  '-scsUTF-8',            # console charset
    218                  '-tzip'                         # force ZIP format
    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  # file decryption methods
    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                  ##'--use-embedded-filename'                             # not all encrypted files carry a filename
    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                          ##'--debug-all',                                                # will log passphrase
    271                          ##'--debug, 'ipc',                                              # will log passphrase
    272                          ##'--debug-level', 'guru',                              # will log passphrase
    273                          ##'--debug-level', '9',                                 # will log passphrase
    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  # file encryption methods
    283  #---------------------------------------------------------------------------
    284 -def gpg_encrypt_file_symmetric(filename=None, comment=None, verbose=False, passphrase=None, remove_unencrypted=False):
    285
    286          #add short decr instr to comment
    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                          ##'--debug-all',                                                # will log passphrase
    318                          ##'--debug, 'ipc',                                              # will log passphrase
    319                          ##'--debug-level', 'guru',                              # will log passphrase
    320                          ##'--debug-level', '9',                                 # will log passphrase
    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          #add 7z/winzip url to comment.txt
    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          # pdf ?
    431          enc_filename = encrypt_pdf(filename = filename, passphrase = passphrase, verbose = verbose)
    432          if enc_filename is not None:
    433                  return enc_filename
    434          # try 7z based AES
    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          # try GPG based
    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          # cannot do asymmetric
    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          # asymmetric not implemented yet
    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  # main
    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          # for testing:
    501          logging.basicConfig(level = logging.DEBUG)
    502          from Gnumed.pycommon import gmI18N
    503          gmI18N.activate_locale()
    504          gmI18N.install_domain()
    505
    506          #-----------------------------------------------------------------------
    507 -        def test_gpg_decrypt():
    508                  print(gpg_decrypt_file(filename = sys.argv[2], verbose = True))
    509
    510          #-----------------------------------------------------------------------
    511 -        def test_gpg_encrypt_symmetric():
    512                  print(gpg_encrypt_file_symmetric(filename = sys.argv[2], passphrase = sys.argv[3], verbose = True, comment = 'GNUmed testing'))
    513
    514          #-----------------------------------------------------------------------
    515 -        def test_aes_encrypt():
    516                  print(aes_encrypt_file(filename = sys.argv[2], passphrase = sys.argv[3], comment = sys.argv[4], verbose = True))
    517
    518          #-----------------------------------------------------------------------
    519 -        def test_encrypt_pdf():
    520                  print(encrypt_pdf(filename = sys.argv[2], passphrase = sys.argv[3], verbose = True))
    521
    522          #-----------------------------------------------------------------------
    523 -        def test_encrypt_file():
    524                  print(encrypt_file(filename = sys.argv[2], passphrase = sys.argv[3], verbose = True))
    525
    526          #-----------------------------------------------------------------------
    527 -        def test_zip_archive_from_dir():
    528                  print(create_zip_archive_from_dir (
    529                          sys.argv[2],
    530                          #archive_name=None,
    531                          comment = 'GNUmed test archive',
    532                          overwrite = True,
    533                          verbose = True
    534                  ))
    535
    536          #-----------------------------------------------------------------------
    537 -        def test_encrypted_zip_archive_from_dir():
    538                  print(create_encrypted_zip_archive_from_dir (
    539                          sys.argv[2],
    540                          comment = 'GNUmed test archive',
    541                          overwrite = True,
    542                          passphrase = sys.argv[3],
    543                          verbose = True
    544                  ))
    545
    546          #-----------------------------------------------------------------------
    547          # encryption
    548          #test_aes_encrypt()
    549          test_encrypt_pdf()
    550          #test_gpg_encrypt_symmetric()
    551          #test_encrypt_file()
    552
    553          # decryption
    554          #test_gpg_decrypt()
    555
    556          #test_zip_archive_from_dir()
    557          #test_encrypted_zip_archive_from_dir()
    558