view sat/memory/crypto.py @ 3160:330a5f1d9eea

core (memory/crypto): replaced `PyCrypto` by `cryptography`: `PyCrypto` is unmaintained for years but was used in SàT for password hashing. This patch fixes that by replacing `PyCrypto` by the reference `cryptography` module which is well maintained. The behaviour stays the same (except that previously async `hash`, `encrypt` and `decrypt` methods are now synchronous, as they are quick and using a deferToThread may actually be more resource intensive than using blocking methods). It is planed to improve `memory.crypto` by using more up-to-date cryptography/hashing algorithms in the future. PyCrypto is no more a dependency of SàT
author Goffi <goffi@goffi.org>
date Sun, 09 Feb 2020 23:50:26 +0100
parents 559a625a236b
children be6d91572633
line wrap: on
line source

#!/usr/bin/env python3

# SAT: a jabber client
# Copyright (C) 2009-2020 Jérôme Poisson (goffi@goffi.org)
# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

from os import urandom
from base64 import b64encode, b64decode
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend


crypto_backend = default_backend()


class BlockCipher:

    BLOCK_SIZE = 16
    MAX_KEY_SIZE = 32
    IV_SIZE = BLOCK_SIZE  # initialization vector size, 16 bits

    @staticmethod
    def encrypt(key, text, leave_empty=True):
        """Encrypt a message.

        Based on http://stackoverflow.com/a/12525165

        @param key (unicode): the encryption key
        @param text (unicode): the text to encrypt
        @param leave_empty (bool): if True, empty text will be returned "as is"
        @return (D(str)): base-64 encoded encrypted message
        """
        if leave_empty and text == "":
            return ""
        iv = BlockCipher.getRandomKey()
        key = key.encode()
        key = (
            key[: BlockCipher.MAX_KEY_SIZE]
            if len(key) >= BlockCipher.MAX_KEY_SIZE
            else BlockCipher.pad(key)
        )

        cipher = Cipher(algorithms.AES(key), modes.CFB8(iv), backend=crypto_backend)
        encryptor = cipher.encryptor()
        encrypted = encryptor.update(BlockCipher.pad(text.encode())) + encryptor.finalize()
        return b64encode(iv + encrypted).decode()

    @staticmethod
    def decrypt(key, ciphertext, leave_empty=True):
        """Decrypt a message.

        Based on http://stackoverflow.com/a/12525165

        @param key (unicode): the decryption key
        @param ciphertext (base-64 encoded str): the text to decrypt
        @param leave_empty (bool): if True, empty ciphertext will be returned "as is"
        @return: Deferred: str or None if the password could not be decrypted
        """
        if leave_empty and ciphertext == "":
            return ""
        ciphertext = b64decode(ciphertext)
        iv, ciphertext = (
            ciphertext[: BlockCipher.IV_SIZE],
            ciphertext[BlockCipher.IV_SIZE :],
        )
        key = key.encode()
        key = (
            key[: BlockCipher.MAX_KEY_SIZE]
            if len(key) >= BlockCipher.MAX_KEY_SIZE
            else BlockCipher.pad(key)
        )

        cipher = Cipher(algorithms.AES(key), modes.CFB8(iv), backend=crypto_backend)
        decryptor = cipher.decryptor()
        decrypted = decryptor.update(ciphertext) + decryptor.finalize()
        return BlockCipher.unpad(decrypted)

    @staticmethod
    def getRandomKey(size=None, base64=False):
        """Return a random key suitable for block cipher encryption.

        Note: a good value for the key length is to make it as long as the block size.

        @param size: key length in bytes, positive or null (default: BlockCipher.IV_SIZE)
        @param base64: if True, encode the result to base-64
        @return: str (eventually base-64 encoded)
        """
        if size is None or size < 0:
            size = BlockCipher.IV_SIZE
        key = urandom(size)
        return b64encode(key) if base64 else key

    @staticmethod
    def pad(s):
        """Method from http://stackoverflow.com/a/12525165"""
        bs = BlockCipher.BLOCK_SIZE
        return s + (bs - len(s) % bs) * (chr(bs - len(s) % bs)).encode()

    @staticmethod
    def unpad(s):
        """Method from http://stackoverflow.com/a/12525165"""
        s = s.decode()
        return s[0 : -ord(s[-1])]


class PasswordHasher:

    SALT_LEN = 16  # 128 bits

    @staticmethod
    def hash(password, salt=None, leave_empty=True):
        """Hash a password.

        @param password (str): the password to hash
        @param salt (base-64 encoded str): if not None, use the given salt instead of a random value
        @param leave_empty (bool): if True, empty password will be returned "as is"
        @return: Deferred: base-64 encoded str
        """
        if leave_empty and password == "":
            return ""
        salt = (
            b64decode(salt)[: PasswordHasher.SALT_LEN]
            if salt
            else urandom(PasswordHasher.SALT_LEN)
        )

        # we use PyCrypto's PBKDF2 arguments while porting to crytography, to stay
        # compatible with existing installations. But this is temporary and we need
        # to update them to more secure values.
        kdf = PBKDF2HMAC(
            # FIXME: SHA1() is not secure, it is used here for historical reasons
            #   and must be changed as soon as possible
            algorithm=hashes.SHA1(),
            length=16,
            salt=salt,
            iterations=1000,
            backend=crypto_backend
        )
        key = kdf.derive(password.encode())
        return b64encode(salt + key).decode()

    @staticmethod
    def verify(attempt, pwd_hash):
        """Verify a password attempt.

        @param attempt (str): the attempt to check
        @param pwd_hash (str): the hash of the password
        @return: Deferred: boolean
        """
        assert isinstance(attempt, str)
        assert isinstance(pwd_hash, str)
        leave_empty = pwd_hash == ""
        attempt_hash = PasswordHasher.hash(attempt, pwd_hash, leave_empty)
        assert isinstance(attempt_hash, str)
        return attempt_hash == pwd_hash