Source

hashish / hashish.py

Full commit
"""
.. module:: hashish
   :platform: Unix, Windows
   :synopsis: A small mixin module that provides password hashing

.. moduleauthor:: Russell Hay <me@russellhay.com>

"""

import string
import random
import hashlib
import logging ; logger = logging.getLogger(__name__)

SALT_CHARACTERS = string.letters + string.digits
SHA512_LEGACY = -1
PBKDF2 = 1
BCRYPT = 2
SHA512 = 3
_HASH_MAPPING = {
    'PBKDF2': PBKDF2,
    'BCRYPT': BCRYPT,
    'SHA512': SHA512
}

class InvalidPasswordError(Exception):
    """ Invalid Password was provided to the validate method """

class HashishMixin(object):
    """ HashishMixin adds pasword hashing to an object.

    Example Usage with SqlAlchemy:

        class Account(Base,HashishMixin):
            __PASSWORD_FIELD__ = "password_hash"  # Defaults to "hash"
            __SALT_COUNT__ = 4  # Defaults to 2

            password_hash = Column(String)


        test_account = Account()
        test_account.password = "SuperSecretPasswordYo!"
        test_account.validate("SuperSecretPasswordYo!")  # True if the hashes match using the same salt.

    This updates the password_hash field with a hashed password
    """

    __PASSWORD_FIELD__ = "hash"
    __SALT_COUNT__ = 10

    @property
    def password(self):
        raise AttributeError("Password is not readable")

    @property
    def hash_type(self):
        hash_value = getattr(self, self.__PASSWORD_FIELD__)
        if hash_value[:2] != "$$":
            return SHA512_LEGACY
        (v, _) = hash_value.split(";")[2:]
        return _HASH_MAPPING[v]

    @password.setter
    def password(self, value):
        """ Generate the password has by setting this property

        :param value: The data to be hashed, most-likely the password.
        :type value: str.

        """
        salt = self.salt
        data = "{salt};{value}".format(salt=salt,
                                       value=self._generate_hash(salt, value))
        setattr(self, self.__PASSWORD_FIELD__, data)

    @property
    def salt(self):
        """ The password salt, if it doesn't exist, one is generated

        :returns: string -- a salt value

        """
        if not hasattr(self, "_salt") or self._salt is None:
            self._salt = self._generate_salt()

        return self._salt

    @salt.setter
    def salt(self, value):
        """ The password salt

        :param value: The salt, must be __SALT_COUNT__ characters in length.
        :param type: str.
        :raises: ValueError

        """
        if value is None:
            self._salt = None
            return

        if len(value) != self.__SALT_COUNT__:
            raise ValueError(
                "Salt must be {0} characters".format(self.__SALT_COUNT__))

        self._salt = str(value)

    def _validate_legacy(self, hash_value, throw, value):
        salt = hash_value[:self.__SALT_COUNT__]
        expected = hash_value[(self.__SALT_COUNT__ + 1):]
        actual = self._generate_hash(salt, value)
        comparison = [ord(a) ^ ord(b) for a, b in zip(actual, expected)]
        valid_password = sum(comparison) == 0
        if throw and not valid_password:
            raise InvalidPasswordError()
        return valid_password

    def validate(self, value, throw=False):
        """ Validate that a password matches the password hash

        :param value: The value to check.
        :type value: str.
        :param throw: Throw an exception on invalid password
        :type throw: bool.
        :returns: bool -- Is the password matches or not
        :raises: InvalidPasswordError

        """
        hash_value = getattr(self, self.__PASSWORD_FIELD__)
        hash_type = self.hash_type
        if hash_type == SHA512_LEGACY:
            valid_password = self._validate_legacy(hash_value, throw, value)
            if valid_password:
                logging.debug("Upgrading password")
                self._upgrade_password(value)

            return valid_password

        return False

    def _generate_hash(self, salt, value):
        hash_string = "{salt};{value}".format(salt=salt, value=value)
        return hashlib.sha512(hash_string).hexdigest()

    def _generate_salt(self):
        return "".join((random.choice(SALT_CHARACTERS) for x in range(self.__SALT_COUNT__)))