Source code for privex.helpers.crypto.EncryptHelper

import binascii
import base64
import logging
from io import TextIOWrapper
from typing import Optional, Union, Tuple, Type
# characters that shouldn't be mistaken
from cryptography.exceptions import InvalidSignature
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf import KeyDerivationFunction
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt

from privex.helpers.crypto.base import is_base64
from privex.helpers.exceptions import EncryptKeyMissing, EncryptionError
from privex.helpers.common import empty, random_str, ALPHANUM

log = logging.getLogger(__name__)


[docs]class EncryptHelper: """ Symmetric AES-128 encryption/decryption made painless - wrapper class for :py:class:`cryptography.fernet.Fernet` A wrapper class for the :py:class:`cryptography.fernet.Fernet` encryption system, designed to make usage of Fernet as painless as possible. The class :class:`.EncryptHelper` contains various methods for simplifying the use of the Python library :doc:`Cryptography <cryptography:index>` 's :py:class:`cryptography.fernet.Fernet` system. :py:meth:`.encrypt_str` / :py:meth:`.decrypt_str` facilitate painless encryption and decryption of data using AES-128 CBC. They can either be passed a 32-byte Fernet key (base64 encoded) as an argument, or leave the key as None and they'll try to use the key defined on the :class:`.EncryptHelper` instance at :py:attr:`.EncryptHelper.encrypt_key` **Basic usage:** >>> from privex.helpers import EncryptHelper >>> key = EncryptHelper.generate_key() # Generates a 32-byte symmetric key, returned as a base64 encoded string >>> crypt = EncryptHelper(key) # Create an instance of EncryptHelper, en/decrypting using ``key`` by default # Encrypts the string 'hello world' with AES-128 CBC using the instance's key, returned as a base64 string >>> enc = crypt.encrypt_str('hello world') >>> print(enc) gAAAAABc7ERTpu2D_uven3l-KtU_ewUC8YWKqXEbLEKrPKrKWT138MNq-I9RRtCD8UZLdQrcdM_IhUU6r8T16lQkoJZ-I7N39g== >>> crypt.is_encrypted(enc) # Check if a string/bytes is encrypted (only works with data matching the key) True >>> data = crypt.decrypt_str(enc) # Decrypt the encrypted data using the same key, outputs as a string >>> print(data) hello world """ encrypt_key: str """A base64 encoded :class:`.Fernet` key, used by default for functions such as :py:meth:`.encrypt_str`"""
[docs] def __init__(self, encrypt_key: str, **kwargs): """ Create an instance of :class:`.EncryptHelper` using the :py:class:`cryptography.fernet.Fernet` key ``encrypt_key`` as the default key for encrypting/decrypting data. :param str encrypt_key: Base64 encoded Fernet key, used by default for encrypting/decrypting data """ self.settings = {**dict(encrypt_key=encrypt_key), **kwargs} self.encrypt_key = encrypt_key
[docs] @staticmethod def generate_key(output: Optional[Union[str, TextIOWrapper]] = None, mode='w') -> str: """ Generate a compatible encryption key for use with :py:class:`cryptography.fernet.Fernet` **NOTE:** Regardless of whether or not the method is outputting the key to a filename / stream, this method will always return the encryption key as a string after completion. The key returning was redacted from the outputting examples to help readability. **Examples** With no arguments, it will simply return the key as a string. >>> EncryptHelper.generate_key() '6vJ_o8XQRmX_TgUFTWWV_U2vm71ThnpWsCIvgXFWg9s=' If ``output`` is a ``str`` - it's assumed to be a filename, and the Fernet key will be outputted to the file ``output`` using ``open(output, mode)`` (where ``mode`` defaults to ``'w'``). Below, we call ``generate_key`` with the string **test.key.txt** - and we can then see the file was created and contains the Fernet key encoded with Base64. >>> EncryptHelper.generate_key('test.key.txt') >>> open('test.key.txt').read() 'aRDR-gCrmrPrMr9hQnL4epIPl2Szbzfid_vSTO-rl20=' If ``output`` is a file/stream object, the method ``output.write(key)`` will be called, where ``key`` is the Fernet key as a string. Below, we open **test2.key.txt** in write mode manually, then pass the file stream object to generate_key, which writes the key to the file. >>> with open('test2.key.txt', 'w') as fp: ... EncryptHelper.generate_key(fp) >>> open('test2.key.txt').read() 'DAFEvRkwG7ws0ccjIv2QL_s5cpeWktqpbc7eSjL-V74=' :param None output: Simply return the generated key :param str output: Output the generated key to the filename ``output`` using the open mode ``mode`` :param TextIOWrapper output: Output the generated key to the file/stream object ``output`` using ``.write(key: str)`` :param str mode: If you're passing a string filename as ``output`` - then this controls the ``open()`` mode, e.g. 'w', 'a', 'w+' :return str key: The generated Fernet key, encoded with Base64. """ key = Fernet.generate_key().decode('utf-8') if type(output) is str: log.debug('Outputting Fernet key to file "%s" using open mode "%s"', output, mode) with open(output, mode) as fp: fp.write(key) elif isinstance(output, TextIOWrapper): log.debug('Outputting Fernet key to file stream object: "%s"', repr(output)) output.write(key) return key
[docs] @staticmethod def password_key(password, salt=None, kdf: Type[KeyDerivationFunction] = PBKDF2HMAC, **kwargs) -> Tuple[str, dict]: """ Generate a :py:class:`cryptography.fernet.Fernet` key based on a password and salt. Key derivation is customisable. :param password: A password to generate the key from, as ``str`` or ``bytes`` :param salt: The salt to use when generating the key, as ``str`` or ``bytes`` If ``salt`` is a string, it can also be passed in base64 format. **Standard Usage with manual salt:** Call ``password_key`` with a password of your choice, and a salt of your choice (ideally at least 16 chars), and a tuple containing the Fernet key (base64 encoded), and key derivative configuration will be returned. >>> ek = EncryptHelper >>> key, kd = ek.password_key('MySecurePass', salt=b'Sup3rseCr3tsalt') >>> key 'rJ_g-lBT7pxeu4MVrhfi5rAv9yLbX5pTm6vkJj_Mezc=' >>> kd {'length': 32, 'salt': 'U3VwM3JzZUNyM3RzYWx0', 'backend': <...Backend object at 0x7fd1c0220eb8>, 'algorithm': <...SHA256 object at 0x7fd1b0232278>, 'iterations': 100000, 'kdf': <class '...PBKDF2HMAC'>} You can see when we call the method a second time with the same password and salt, we get the same Fernet key. >>> key, kd = ek.password_key('MySecurePass', salt=b'Sup3rseCr3tsalt') >>> key 'rJ_g-lBT7pxeu4MVrhfi5rAv9yLbX5pTm6vkJj_Mezc=' Now we can simply initialise the class with this key, and start encrypting/decrypting data: >>> enc = EncryptHelper(key) >>> mydata = enc.encrypt_str('hello') >>> mydata 'gAAAAABdsJrpZvQAhAEwAGk2GPeJMUjUdp1FHAg42ncArvvQjqGztLslgexF7dKWbJ8bhYNt9MBzzT0WR_XEvl1j5Q95UOVTsQ==' >>> enc.decrypt_str(mydata) 'hello' **Automatic salt generation:** While it's strongly recommend that you pass your own ``salt`` (at least 16 bytes recommended), for convenience this method will automatically generate a 16 byte salt and return it as part of the dict (second tuple item) returned. First, we generate a key from the password ``helloworld`` >>> ek = EncryptHelper >>> key, kd = ek.password_key('helloworld') >>> key '6asAQ0qTQtmjw54RBR_RVmwsyv6EgTY_lcnVgJAVKCQ=' >>> kd {'length': 32, 'salt': 'bDU5MzJaaEhnZ1htSmlQeg==', 'backend': <...Backend object at 0x7ff968053860>, 'algorithm': <...SHA256 object at 0x7ff9685f6160>, 'iterations': 100000, 'kdf': <class '...PBKDF2HMAC'>} If we call **password_key** again with ``helloworld``, you'll notice it outputs a completely different key. This is because no salt was specified, so it simply generated yet another salt. >>> ek.password_key('helloworld')[0] 'BfesIzfEPodtHSyPrpnkK0iDipHikaE7T1uuFFPnqmc=' To actually get the same Fernet key back, we have to either: * Pass the entire ``kd`` dictionary as kwargs (safest option, contains all params used for generation) >>> ek.password_key('helloworld', **kd)[0] '6asAQ0qTQtmjw54RBR_RVmwsyv6EgTY_lcnVgJAVKCQ=' * Pass the generated salt from the ``kd`` object, alongside our password. >>> ek.password_key('helloworld', salt=kd['salt'])[0] '6asAQ0qTQtmjw54RBR_RVmwsyv6EgTY_lcnVgJAVKCQ=' """ had_salt = salt is not None # If we were passed a salt, and it was a string, then check if it appears to be Base64 # If the salt was Base64, then decode it into bytes. salt = base64.urlsafe_b64decode(salt) if had_salt and type(salt) is str and is_base64(salt) else salt # Generate a salt if one wasn't passed, and ensure that ``salt`` is bytes salt = random_str(16, chars=ALPHANUM) if not had_salt else salt salt = bytes(salt, 'utf-8') if type(salt) is not bytes else salt password = bytes(password, 'utf-8') if type(password) is not bytes else password # Default kwargs to be passed to the key deriv function if user doesn't override them. defaults = dict(length=32, salt=salt, backend=default_backend()) if kdf is PBKDF2HMAC: defaults = {**defaults, **dict(algorithm=hashes.SHA256(), iterations=100000)} if kdf is Scrypt: defaults = {**defaults, **dict(n=2 ** 20, r=8, p=1)} # Merge our defaults with the users kwargs, initialise the KDF, then derive a Fernet key from the password kdf_args = {**defaults, **kwargs} k = kdf(**kdf_args) key = base64.urlsafe_b64encode(k.derive(password)).decode('utf-8') # To assist with future runs of this function, we return a dict containing the name of the KDF class used, # the salt (base64 encoded for easy handling), plus all of the KDF kwargs including our defaults kdf_args['salt'] = base64.urlsafe_b64encode(salt).decode('utf-8') kdf_args['kdf'] = kdf return key, kdf_args
[docs] @classmethod def from_password(cls, password: Union[str, bytes], salt: Union[str, bytes], **settings): """ Create an instance of :class:`.EncryptHelper` (or inheriting class) from a password derived Fernet key, instead of a pre-generated Fernet key. See :py:meth:`.password_key` for more detailed usage information. **Example** >>> enc = EncryptHelper.from_password('MySecurePass', salt=b'Sup3rseCr3tsalt') >>> d = enc.encrypt('hello') >>> enc.decrypt(d) 'hello' :param password: A password to generate the key from, as ``str`` or ``bytes`` :param salt: The salt to use when generating the key, as ``str`` or ``bytes`` If ``salt`` is a string, it can also be passed in base64 format. """ key, _ = cls.password_key(password=password, salt=salt, **settings) return cls(encrypt_key=key)
[docs] @classmethod def from_file(cls, obj: Union[str, TextIOWrapper], **settings): """ Create an instance of :class:`.EncryptHelper` (or inheriting class) using a Fernet key loaded from a file, or stream object. >>> enc = EncryptHelper.from_file('/home/john/fernet.key') >>> d = enc.encrypt('hello') >>> enc.decrypt(d) 'hello' :param str obj: Load the key from the filename ``obj`` :param TextIOWrapper obj: Load the key from the file/stream object ``obj`` using ``.read()`` """ if isinstance(obj, TextIOWrapper): return cls(encrypt_key=obj.read(), **settings) with open(str(obj)) as fp: return cls(encrypt_key=fp.read(), **settings)
[docs] def get_fernet(self, key: Union[str, bytes] = None) -> Fernet: """ Used internally for getting Fernet instance with auto-fallback to :py:attr:`.encrypt_key` and exception handling. :param str key: Base64 Fernet symmetric key for en/decrypting data. If empty, will fallback to :py:attr:`.encrypt_key` :raises EncryptKeyMissing: Either no key was passed, or something is wrong with the key. :return Fernet f: Instance of Fernet using passed ``key`` or self.encrypt_key for encryption. """ if empty(key) and empty(self.encrypt_key): raise EncryptKeyMissing('No key argument passed, and ENCRYPT_KEY is empty. Cannot encrypt/decrypt.') key = self.encrypt_key if empty(key) else key try: f = Fernet(key) return f except (binascii.Error, ValueError): raise EncryptKeyMissing('The passed ``key`` or self.encrypt_key is not a valid Fernet key')
[docs] def is_encrypted(self, data: Union[str, bytes], key: Union[str, bytes] = None) -> bool: """ Returns True if the passed ``data`` appears to be encrypted. Can only verify encryption if the same ``key`` that was used to encrypt the data is passed. :param str data: The data to check for encryption, either as a string or bytes :param str key: Base64 encoded Fernet symmetric key for decrypting data. If empty, fallback to :py:attr:`.encrypt_key` :raises EncryptKeyMissing: Either no key was passed, or something is wrong with the key. :return bool is_encrypted: True if the data is encrypted, False if it's not encrypted or wrong key used. """ f = self.get_fernet(key) # Convert the passed data into bytes before trying to decode it data = str(data).encode('utf-8') if type(data) != bytes else data # Attempt to extract the Fernet timestamp from the passed data. If exceptions are raised, then it's not encrypted. try: ts = f.extract_timestamp(data) log.debug(f'data was encrypted, token timestamp is {ts}') return True except (InvalidSignature, InvalidToken, binascii.Error) as e: log.debug('data is not encrypted? exception was: %s %s', type(e), str(e)) return False
def _crypt_str(self, direction: str, data: Union[str, bytes], key: Union[str, bytes] = None) -> str: """ Used internally by :py:meth:`.encrypt_str` and :py:meth:`.decrypt_str` :param str direction: Either 'encrypt' or 'decrypt' :param str data: The data to encrypt or decrypt as either a string or bytes :param str key: Base64 encoded Fernet symmetric key for encrypting/decrypting data. :return str data_out: Either the encrypted data as a base64 encoded string, or decrypted data as a plain string. """ if direction not in ['encrypt', 'decrypt']: raise ValueError('_crypt_str direction must be "encrypt" or "decrypt"') f = self.get_fernet(key) # Handle encryption/decryption of ``data`` try: # If ``data`` isn't already bytes, cast to a string and convert it to bytes before encrypting/decrypting data = str(data).encode('utf-8') if type(data) != bytes else data out = f.encrypt(data) if direction == 'encrypt' else f.decrypt(data) return out.decode() # Return encrypted/decrypted data as a string, not bytes. except Exception: strdat = str(data) if type(data) != bytes else str(data.decode()) log.exception(f'An exception occurred while trying to {direction} the data starting with "{strdat:.4}"...') raise EncryptionError(f'Failed to {direction} data... An admin must check the logs.')
[docs] def encrypt_str(self, data: Union[str, bytes], key: Union[str, bytes] = None) -> str: """ Encrypts a piece of data ``data`` passed as a string or bytes using Fernet with the passed 32-bit symmetric encryption key ``key``. Outputs the encrypted data as a Base64 string for easy storage. The ``key`` cannot just be a random "password", it must be a 32-byte key encoded with URL Safe base64. Use the method :py:meth:`.generate_key` to create a Fernet compatible encryption key. Under the hood, Fernet uses AES-128 CBC to encrypt the data, with PKCS7 padding and HMAC_SHA256 authentication. If the ``key`` parameter isn't passed, or is empty (None / ""), then it will attempt to fall back to ``self.encrypt_key`` - if that's also empty, EncryptKeyMissing will be raised. :param str data: The data to be encrypted, in the form of either a str or bytes. :param str key: A Fernet encryption key (base64) to be used, if left blank will fall back to :py:attr:`.encrypt_key` :raises EncryptKeyMissing: Either no key was passed, or something is wrong with the key. :raises EncryptionError: Something went wrong while attempting to encrypt the data :return str encrypted_data: The encrypted version of the passed ``data`` as a base64 encoded string. """ return self._crypt_str('encrypt', data, key)
[docs] def decrypt_str(self, data: Union[str, bytes], key: Union[str, bytes] = None) -> str: """ Decrypts ``data`` previously encrypted using :py:meth:`.encrypt_str` with the same Fernet compatible ``key``, and returns the decrypted version as a string. The ``key`` cannot just be a random "password", it must be a 32-byte key encoded with URL Safe base64. Use the method :py:meth:`.generate_key` to create a Fernet compatible encryption key. Under the hood, Fernet uses AES-128 CBC to encrypt the data, with PKCS7 padding and HMAC_SHA256 authentication. If the ``key`` parameter isn't passed, or is empty (None / ""), then it will attempt to fall back to :py:attr:`.encrypt_key` - if that's also empty, EncryptKeyMissing will be raised. :param str data: The base64 encoded data to be decrypted, in the form of either a str or bytes. :param str key: A Fernet encryption key (base64) for decryption, if blank, will fall back to :py:attr:`.encrypt_key` :raises EncryptKeyMissing: Either no key was passed, or something is wrong with the key. :raises EncryptionError: Something went wrong while attempting to decrypt the data :return str decrypted_data: The decrypted data as a string """ return self._crypt_str('decrypt', data, key)