2
\$\begingroup\$

I implemented a password field based on the peewee framework to handle passwords of accounts in web applications for Python 3.4.

My goals were

  1. Secure hashing.
  2. No plain password leakage.
  3. Easy password verification.

And this is what I came up with:

peeweeplus.passwd:

"""Argon2-based password hashing."""

from argon2 import PasswordHasher
from argon2.exceptions import VerificationError, VerifyMismatchError
from peewee import FieldAccessor

from peeweeplus.exceptions import PasswordTooShortError


__all__ = ['PASSWORD_HASHER', 'is_hash', 'Argon2Hash', 'Argon2FieldAccessor']


PASSWORD_HASHER = PasswordHasher()
_MIN_PW_LEN = 8


def is_hash(hasher, value):
    """Determines whether value is a valid Argon2 hash for hasher."""

    try:
        return hasher.verify(value, '')
    except VerifyMismatchError:
        return True
    except VerificationError:
        return False


class Argon2Hash(str):
    """An Argon2 hash."""

    def __new__(cls, _, hash_):
        """Override str constructor."""
        return str.__new__(cls, hash_)

    def __init__(self, hasher, hash_):
        """Sets the hasher."""
        super().__init__()

        if not is_hash(hasher, hash_):
            raise ValueError('Not an Argon2 hash.')

        self._hasher = hasher

    @classmethod
    def create(cls, hasher, passwd):
        """Creates a hash from the respective hasher and password."""
        return cls(hasher, hasher.hash(passwd))

    def verify(self, passwd):
        """Validates the plain text password against this hash."""
        return self._hasher.verify(self, passwd)


class Argon2FieldAccessor(FieldAccessor):
    """Accessor class for Argon2Field."""

    def __get__(self, instance, instance_type=None):
        """Returns an Argon2 hash."""
        value = super().__get__(instance, instance_type=instance_type)

        if instance is not None:
            if value is None:
                return None

            return Argon2Hash(self.field.hasher, value)

        return value

    def __set__(self, instance, value):
        """Sets the password hash."""
        if value is not None:
            if isinstance(value, Argon2Hash):
                value = str(value)
            else:
                # If value is a plain text password, hash it.
                if len(value) < _MIN_PW_LEN:
                    raise PasswordTooShortError(len(value), _MIN_PW_LEN)

                value = self.field.hasher.hash(value)

        super().__set__(instance, value)

The actual field:

class PasswordField(FixedCharField):
    """Common base class for password
    fields to identify them as such.
    """

    pass


class Argon2Field(PasswordField):
    """An Argon2 password field."""

    accessor_class = Argon2FieldAccessor

    def __init__(self, max_length=None, hasher=PASSWORD_HASHER, **kwargs):
        """Initializes the char field, defaulting
        max_length to the respective hash length.
        """
        if max_length is None:
            max_length = len(hasher.hash(''))

        super().__init__(max_length=max_length, **kwargs)
        self.hasher = hasher

    def python_value(self, value):
        """Returns an Argon2 hash."""
        if value is None:
            return None

        return Argon2Hash(self.hasher, value)

Any critique is welcome.

Example usage:

>>> from his import Account
>>> account = Account.get(Account.name == 'neumann')
>>> account.passwd = 'top secret'
>>> account.passwd
'$argon2i$v=19$m=512,t=2,p=2$ROFipndj8u2hbr8UhaPOcQ$BcEf+z8mHnYjKYZnausIyA'
>>> account.passwd.verify('wrong')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python3.4/dist-packages/peeweeplus/passwd.py", line 51, in verify
    return self._hasher.verify(self, passwd)
  File "/usr/local/lib/python3.4/dist-packages/argon2/_password_hasher.py", line 133, in verify
    Type.I,
  File "/usr/local/lib/python3.4/dist-packages/argon2/low_level.py", line 160, in verify_secret
    raise VerifyMismatchError(error_to_str(rv))
argon2.exceptions.VerifyMismatchError: The password does not match the supplied hash
>>> account.passwd.verify('top secret')
True

API notice:

In peewee, when data is retrieved from the database table and "put" on the model instance, the raw data will be converted first using the respective field's python_value method.
Then the respective property is set, which is actually the corresponding FieldAccessor using its __set__ method.
When accessing model field properties, this is analogously proxied through the respective field accessor's __get__ method.

Library notice:

I am using argon2_cffi as argon2 library.

\$\endgroup\$
0

1 Answer 1

3
\$\begingroup\$

All in all, I find the code very well written.

I don't really understand the purpose of the Argon2Hash.create class method, but I'd venture it is for convenience and testing, so it's harmless to keep it; with a slight change, though:

class Argon2Hash(str):
    @classmethod
    def create(cls, password, hasher=PASSWORD_HASHER):
        return cls(hasher, hasher.hash(password))
    ...

so it is easier to create hashs from strings using the default hasher. And for consistency sake, you should probably switch the order of the arguments for __new__ and __init__ as well.


I am also uneasy with the way you handle password and field length. What would happen if I used an Argon2Field and provided a max_length that happen to be less than what hasher.hash return? I bet that upon saving the model using such field into the database it would raise some kind of validation error.

In the same vein, there is no easy way to specify a minimum password length per field (minimum in the sense of raw data, not hashed one).

So, instead of enabling the user to override max_length, I would always compute it from the hasher; and I’d add an option to specify the minimal acceptable length for the raw password:

class Argon2Field(PasswordField):
    """An Argon2 password field."""

    accessor_class = Argon2FieldAccessor

    def __init__(self, min_length=None, hasher=PASSWORD_HASHER, **kwargs):
        """Initializes the char field, defaulting
        max_length to the respective hash length.
        """

        max_length = len(hasher.hash(''))
        super().__init__(max_length=max_length, **kwargs)

        self.min_password_length = min_length
        self.hasher = hasher

    ...


class Argon2FieldAccessor(FieldAccessor):
    """Accessor class for Argon2Field."""

    ...

    def __set__(self, instance, value):
        """Sets the password hash."""
        if value is not None:
            if isinstance(value, Argon2Hash):
                value = str(value)
            else:
                # If value is a plain text password, hash it.
                min_length = self.field.min_password_length
                if min_length is not None and len(value) < min_length:
                    raise PasswordTooShortError(len(value), min_length)

                value = self.field.hasher.hash(value)

        super().__set__(instance, value)
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.