Gnumed.pycommon.gmCrypto

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
     20
     21  # GNUmed libs
     22  if __name__ == '__main__':
     23          sys.path.insert(0, '../../')
     24  from Gnumed.pycommon import gmLog2
     25  from Gnumed.pycommon import gmShellAPI
     26  from Gnumed.pycommon import gmTools
     27
     28
     29  _log = logging.getLogger('gm.encryption')
     30
     31  #===========================================================================
     32  # archiving methods
     33  #---------------------------------------------------------------------------
     34 -def create_encrypted_zip_archive_from_dir(source_dir, comment=None, overwrite=True, passphrase=None, verbose=False):
     35          """Use 7z to create an encrypted ZIP archive of a directory.
     36
     37          <source_dir>            will be included into the archive
     38          <comment>                       included as a file containing the comment
     39          <overwrite>                     remove existing archive before creation, avoiding
     40                                                  *updating* of those, and thereby including unintended data
     41          <passphrase>            minimum length of 5
     42
     43          The resulting zip archive will always be named
     44          "datawrapper.zip" for confidentiality reasons. If callers
     45          want another name they will have to shutil.move() the zip
     46          file themselves. This archive will be compressed and
     47          AES256 encrypted with the given passphrase. Therefore,
     48          the result will not decrypt with earlier versions of
     49          unzip software. On Windows, 7z oder WinZip are needed.
     50
     51          The zip format does not support header encryption thereby
     52          allowing attackers to gain knowledge of patient details
     53          by observing the names of files and directories inside
     54          the encrypted archive.
     55
     56          To reduce that attack surface, GNUmed will create
     57          _another_ zip archive inside "datawrapper.zip", which
     58          eventually wraps up the patient data as "data.zip". That
     59          archive is not compressed and not encrypted, and can thus
     60          be unpacked with any old unzipper.
     61
     62          Note that GNUmed does NOT remember the passphrase for
     63          you. You will have to take care of that yourself, and
     64          possibly also safely hand over the passphrase to any
     65          receivers of the zip archive.
     66          """
     67          if len(passphrase) < 5:
     68                  _log.error('<passphrase> must be at least 5 characters/signs/digits')
     69                  return None
     70          gmLog2.add_word2hide(passphrase)
     71
     72          source_dir = os.path.abspath(source_dir)
     73          if not os.path.isdir(source_dir):
     74                  _log.error('<source_dir> does not exist or is not a directory: %s', source_dir)
     75                  return False
     76
     77          for cmd in ['7z', '7z.exe']:
     78                  found, binary = gmShellAPI.detect_external_binary(binary = cmd)
     79                  if found:
     80                          break
     81          if not found:
     82                  _log.warning('no 7z binary found')
     83                  return None
     84
     85          sandbox_dir = gmTools.mk_sandbox_dir()
     86          archive_path_inner = os.path.join(sandbox_dir, 'data')
     87          if not gmTools.mkdir(archive_path_inner):
     88                  _log.error('cannot create scratch space for inner achive: %s', archive_path_inner)
     89          archive_fname_inner = 'data.zip'
     90          archive_name_inner = os.path.join(archive_path_inner, archive_fname_inner)
     91          archive_path_outer = gmTools.gmPaths().tmp_dir
     92          archive_fname_outer = 'datawrapper.zip'
     93          archive_name_outer = os.path.join(archive_path_outer, archive_fname_outer)
     94          # remove existing archives so they don't get *updated* rather than newly created
     95          if overwrite:
     96                  if not gmTools.remove_file(archive_name_inner, force = True):
     97                          _log.error('cannot remove existing archive [%s]', archive_name_inner)
     98                          return False
     99
    100                  if not gmTools.remove_file(archive_name_outer, force = True):
    101                          _log.error('cannot remove existing archive [%s]', archive_name_outer)
    102                          return False
    103
    104          # 7z does not support ZIP comments so create a text file holding the comment
    105          if comment is not None:
    106                  tmp, fname = os.path.split(source_dir.rstrip(os.sep))
    107                  comment_filename = os.path.join(sandbox_dir, '000-%s-comment.txt' % fname)
    108                  with open(comment_filename, mode = 'wt', encoding = 'utf8', errors = 'replace') as comment_file:
    109                          comment_file.write(comment)
    110
    111          # create inner (data) archive: uncompressed, unencrypted, similar to a tar archive
    112          args = [
    113                  binary,
    114                  'a',                            # create archive
    115                  '-sas',                         # be smart about archive name extension
    116                  '-bd',                          # no progress indicator
    117                  '-mx0',                         # no compression (only store files)
    118                  '-mcu=on',                      # UTF8 filenames
    119                  '-l',                           # store content of links, not links
    120                  '-scsUTF-8',            # console charset
    121                  '-tzip'                         # force ZIP format
    122          ]
    123          if verbose:
    124                  args.append('-bb3')
    125                  args.append('-bt')
    126          else:
    127                  args.append('-bb1')
    128          args.append(archive_name_inner)
    129          args.append(source_dir)
    130          if comment is not None:
    131                  args.append(comment_filename)
    132          success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, encoding = 'utf8', verbose = verbose)
    133          if not success:
    134                  _log.error('cannot create inner archive')
    135                  return None
    136
    137          # create "decompress instructions" file
    138          instructions_filename = os.path.join(archive_path_inner, '000_Windows-open_with-WinZip-or-7z_tools')
    139          open(instructions_filename, mode = 'wt').close()
    140
    141          # create outer (wrapper) archive: compressed, encrypted
    142          args = [
    143                  binary,
    144                  'a',                                    # create archive
    145                  '-sas',                                 # be smart about archive name extension
    146                  '-bd',                                  # no progress indicator
    147                  '-mx9',                                 # best available zip compression ratio
    148                  '-mcu=on',                              # UTF8 filenames
    149                  '-l',                                   # store content of links, not links
    150                  '-scsUTF-8',                    # console charset
    151                  '-tzip',                                # force ZIP format
    152                  '-mem=AES256',                  # force useful encryption
    153                  '-p%s' % passphrase             # set passphrase
    154          ]
    155          if verbose:
    156                  args.append('-bb3')
    157                  args.append('-bt')
    158          else:
    159                  args.append('-bb1')
    160          args.append(archive_name_outer)
    161          args.append(archive_path_inner)
    162          success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, encoding = 'utf8', verbose = verbose)
    163          if success:
    164                  return archive_name_outer
    165          _log.error('cannot create outer archive')
    166          return None
    167
    168  #---------------------------------------------------------------------------
    169 -def create_zip_archive_from_dir(source_dir, archive_name=None, comment=None, overwrite=True, verbose=False):
    170
    171          source_dir = os.path.abspath(source_dir)
    172          if not os.path.isdir(source_dir):
    173                  _log.error('<source_dir> does not exist or is not a directory: %s', source_dir)
    174                  return False
    175
    176          for cmd in ['7z', '7z.exe']:
    177                  found, binary = gmShellAPI.detect_external_binary(binary = cmd)
    178                  if found:
    179                          break
    180          if not found:
    181                  _log.warning('no 7z binary found')
    182                  return None
    183
    184          if archive_name is None:
    185                  # do not assume we can write to "sourcedir/../"
    186                  archive_path = gmTools.gmPaths().tmp_dir
    187                  # but do take archive name from source_dir
    188                  tmp, archive_fname = os.path.split(source_dir.rstrip(os.sep) + '.zip')
    189                  archive_name = os.path.join(archive_path, archive_fname)
    190          # 7z does not support ZIP comments so create a
    191          # text file holding the comment ...
    192          if comment is not None:
    193                  tmp, fname = os.path.split(os.path.abspath(archive_name))
    194                  comment_filename = gmTools.get_unique_filename (
    195                          prefix = '%s.' % fname,
    196                          suffix = '.comment.txt'
    197                  )
    198                  with open(comment_filename, mode = 'wt', encoding = 'utf8', errors = 'replace') as comment_file:
    199                          comment_file.write(comment)
    200          # remove any existing archives so they don't get *updated*
    201          # rather than newly created
    202          if overwrite:
    203                  if not gmTools.remove_file(archive_name, force = True):
    204                          _log.error('cannot remove existing archive [%s]', archive_name)
    205                          return False
    206
    207          # compress
    208          args = [
    209                  binary,
    210                  'a',                            # create archive
    211                  '-sas',                         # be smart about archive name extension
    212                  '-bd',                          # no progress indicator
    213                  '-mx9',                         # best available zip compression ratio
    214                  '-mcu=on',                      # UTF8 filenames
    215                  '-l',                           # store content of links, not links
    216                  '-scsUTF-8',            # console charset
    217                  '-tzip'                         # force ZIP format
    218          ]
    219          if verbose:
    220                  args.append('-bb3')
    221                  args.append('-bt')
    222          else:
    223                  args.append('-bb1')
    224          args.append(archive_name)
    225          args.append(source_dir)
    226          if comment is not None:
    227                  args.append(comment_filename)
    228          success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, encoding = 'utf8', verbose = verbose)
    229          if comment is not None:
    230                  gmTools.remove_file(comment_filename)
    231          if success:
    232                  return archive_name
    233
    234          return None
    235
    236  #===========================================================================
    237  # file decryption methods
    238  #---------------------------------------------------------------------------
    239 -def gpg_decrypt_file(filename=None, passphrase=None, verbose=False, target_ext=None):
    240          assert (filename is not None), '<filename> must not be None'
    241
    242          _log.debug('attempting GPG decryption')
    243          for cmd in ['gpg2', 'gpg', 'gpg2.exe', 'gpg.exe']:
    244                  found, binary = gmShellAPI.detect_external_binary(binary = cmd)
    245                  if found:
    246                          break
    247          if not found:
    248                  _log.warning('no gpg binary found')
    249                  return None
    250
    251          basename = os.path.splitext(filename)[0]
    252          filename_decrypted = gmTools.get_unique_filename(prefix = '%s-decrypted-' % basename, suffix = target_ext)
    253          args = [
    254                  binary,
    255                  '--utf8-strings',
    256                  '--display-charset', 'utf-8',
    257                  '--batch',
    258                  '--no-greeting',
    259                  '--enable-progress-filter',
    260                  '--decrypt',
    261                  '--output', filename_decrypted
    262                  ##'--use-embedded-filename'                             # not all encrypted files carry a filename
    263          ]
    264          if verbose:
    265                  args.extend ([
    266                          '--verbose', '--verbose',
    267                          '--debug-level', '8',
    268                          '--debug', 'packet,mpi,crypto,filter,iobuf,memory,cache,memstat,trust,hashing,clock,lookup,extprog'
    269                          ##'--debug-all',                                                # will log passphrase
    270                          ##'--debug, 'ipc',                                              # will log passphrase
    271                          ##'--debug-level', 'guru',                              # will log passphrase
    272                          ##'--debug-level', '9',                                 # will log passphrase
    273                  ])
    274          args.append(filename)
    275          success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, verbose = verbose, encoding = 'utf-8')
    276          if success:
    277                  return filename_decrypted
    278          return None
    279
    280  #===========================================================================
    281  # file encryption methods
    282  #---------------------------------------------------------------------------
    283 -def gpg_encrypt_file_symmetric(filename=None, comment=None, verbose=False):
    284
    285          #add short decr instr to comment
    286
    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          args.append(filename)
    323          success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, verbose = verbose, encoding = 'utf-8')
    324          if success:
    325                  return filename_encrypted
    326          return None
    327
    328  #---------------------------------------------------------------------------
    329 -def aes_encrypt_file(filename=None, passphrase=None, comment=None, verbose=False):
    330          assert (filename is not None), '<filename> must not be None'
    331          assert (passphrase is not None), '<passphrase> must not be None'
    332
    333          if len(passphrase) < 5:
    334                  _log.error('<passphrase> must be at least 5 characters/signs/digits')
    335                  return None
    336          gmLog2.add_word2hide(passphrase)
    337
    338          #add 7z/winzip url to comment.txt
    339          _log.debug('attempting 7z AES encryption')
    340          for cmd in ['7z', '7z.exe']:
    341                  found, binary = gmShellAPI.detect_external_binary(binary = cmd)
    342                  if found:
    343                          break
    344          if not found:
    345                  _log.warning('no 7z binary found, trying gpg')
    346                  return None
    347
    348          if comment is not None:
    349                  archive_path, archive_name = os.path.split(os.path.abspath(filename))
    350                  comment_filename = gmTools.get_unique_filename (
    351                          prefix = '%s.7z.comment-' % archive_name,
    352                          tmp_dir = archive_path,
    353                          suffix = '.txt'
    354                  )
    355                  with open(comment_filename, mode = 'wt', encoding = 'utf8', errors = 'replace') as comment_file:
    356                          comment_file.write(comment)
    357          else:
    358                  comment_filename = ''
    359          filename_encrypted = '%s.7z' % filename
    360          args = [binary, 'a', '-bb3', '-mx0', "-p%s" % passphrase, filename_encrypted, filename, comment_filename]
    361          encrypted, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, encoding = 'utf8', verbose = verbose)
    362          gmTools.remove_file(comment_filename)
    363          if encrypted:
    364                  return filename_encrypted
    365          return None
    366
    367  #---------------------------------------------------------------------------
    368 -def encrypt_pdf(filename=None, passphrase=None, verbose=False):
    369          assert (filename is not None), '<filename> must not be None'
    370          assert (passphrase is not None), '<passphrase> must not be None'
    371
    372          if len(passphrase) < 5:
    373                  _log.error('<passphrase> must be at least 5 characters/signs/digits')
    374                  return None
    375          gmLog2.add_word2hide(passphrase)
    376
    377          _log.debug('attempting PDF encryption')
    378          for cmd in ['qpdf', 'qpdf.exe']:
    379                  found, binary = gmShellAPI.detect_external_binary(binary = cmd)
    380                  if found:
    381                          break
    382          if not found:
    383                  _log.warning('no qpdf binary found')
    384                  return None
    385
    386          filename_encrypted = '%s.encrypted.pdf' % os.path.splitext(filename)[0]
    387          args = [
    388                  binary,
    389                  '--verbose',
    390                  '--encrypt', passphrase, '', '128',
    391                  '--print=full', '--modify=none', '--extract=n',
    392                  '--use-aes=y',
    393                  '--',
    394                  filename,
    395                  filename_encrypted
    396          ]
    397          success, exit_code, stdout = gmShellAPI.run_process(cmd_line = args, encoding = 'utf8', verbose = verbose)
    398          if success:
    399                  return filename_encrypted
    400          return None
    401
    402  #---------------------------------------------------------------------------
    403 -def encrypt_file_symmetric(filename=None, passphrase=None, comment=None, verbose=False):
    404          assert (filename is not None), '<filename> must not be None'
    405
    406          # pdf ?
    407          enc_filename = encrypt_pdf(filename = filename, passphrase = passphrase, verbose = verbose)
    408          if enc_filename is not None:
    409                  return enc_filename
    410          # try GPG based
    411          enc_filename = gpg_encrypt_file_symmetric(filename = filename, comment = comment, verbose = verbose)
    412          if enc_filename is not None:
    413                  return enc_filename
    414          # try 7z based
    415          return aes_encrypt_file(filename = filename, passphrase = passphrase, comment = comment, verbose = verbose)
    416
    417  #---------------------------------------------------------------------------
    418 -def encrypt_file(filename=None, receiver_key_ids=None, passphrase=None, comment=None, verbose=False):
    419          assert (filename is not None), '<filename> must not be None'
    420
    421          # cannot do asymmetric
    422          if receiver_key_ids is None:
    423                  _log.debug('no receiver key IDs: cannot try asymmetric encryption')
    424                  return encrypt_file_symmetric(filename = filename, passphrase = passphrase, comment = comment, verbose = verbose)
    425
    426          # asymmetric not implemented yet
    427          return None
    428
    429  #===========================================================================
    430  # main
    431  #---------------------------------------------------------------------------
    432  if __name__ == '__main__':
    433
    434          if len(sys.argv) < 2:
    435                  sys.exit()
    436
    437          if sys.argv[1] != 'test':
    438                  sys.exit()
    439
    440          # for testing:
    441          logging.basicConfig(level = logging.DEBUG)
    442          from Gnumed.pycommon import gmI18N
    443          gmI18N.activate_locale()
    444          gmI18N.install_domain()
    445
    446          #-----------------------------------------------------------------------
    447 -        def test_gpg_decrypt():
    448                  print(gpg_decrypt_file(filename = sys.argv[2], verbose = True))
    449
    450          #-----------------------------------------------------------------------
    451 -        def test_gpg_encrypt_symmetric():
    452                  print(gpg_encrypt_file_symmetric(filename = sys.argv[2], verbose = True, comment = 'GNUmed testing'))
    453
    454          #-----------------------------------------------------------------------
    455 -        def test_aes_encrypt():
    456                  print(aes_encrypt_file(filename = sys.argv[2], passphrase = sys.argv[3], comment = sys.argv[4], verbose = True))
    457
    458          #-----------------------------------------------------------------------
    459 -        def test_encrypt_pdf():
    460                  print(encrypt_pdf(filename = sys.argv[2], passphrase = sys.argv[3], verbose = True))
    461
    462          #-----------------------------------------------------------------------
    463 -        def test_encrypt_file():
    464                  print(encrypt_file(filename = sys.argv[2], passphrase = sys.argv[3], verbose = True))
    465
    466          #-----------------------------------------------------------------------
    467 -        def test_zip_archive_from_dir():
    468                  print(create_zip_archive_from_dir (
    469                          sys.argv[2],
    470                          #archive_name=None,
    471                          comment = 'GNUmed test archive',
    472                          overwrite = True,
    473                          verbose = True
    474                  ))
    475
    476          #-----------------------------------------------------------------------
    477 -        def test_encrypted_zip_archive_from_dir():
    478                  print(create_encrypted_zip_archive_from_dir (
    479                          sys.argv[2],
    480                          comment = 'GNUmed test archive',
    481                          overwrite = True,
    482                          passphrase = sys.argv[3],
    483                          verbose = True
    484                  ))
    485
    486          #-----------------------------------------------------------------------
    487          # encryption
    488          #test_aes_encrypt()
    489          #test_encrypt_pdf()
    490          #test_gpg_encrypt_symmetric()
    491          #test_encrypt_file()
    492
    493          # decryption
    494          #test_gpg_decrypt()
    495
    496          #test_zip_archive_from_dir()
    497          test_encrypted_zip_archive_from_dir()
    498