Skip to content

Commit

Permalink
Add secure hashing mechanisms
Browse files Browse the repository at this point in the history
  • Loading branch information
icgood committed Apr 20, 2020
1 parent 2d31f8a commit e5b24eb
Show file tree
Hide file tree
Showing 6 changed files with 168 additions and 6 deletions.
9 changes: 6 additions & 3 deletions pysasl/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@

import hmac
from abc import abstractmethod, ABCMeta
from collections import OrderedDict
from typing import ClassVar, Optional, Iterable, Tuple, Mapping, Sequence

from .hashing import HashInterface, Cleartext

from pkg_resources import iter_entry_points

__all__ = ['AuthenticationError', 'UnexpectedChallenge', 'ServerChallenge',
Expand Down Expand Up @@ -158,20 +159,22 @@ def identity(self) -> str:
else:
return self.authcid

def check_secret(self, secret: str) -> bool:
def check_secret(self, secret: str, *,
hash: HashInterface = Cleartext()) -> bool:
"""Checks if the secret string used in the authentication attempt
matches the "known" secret string. Some mechanisms will override this
method to control how this comparison is made.
Args:
secret: The secret string to compare against what was used in the
authentication attempt.
hash: The hash implementation to use to verify the secret.
Returns:
True if the given secret matches the authentication attempt.
"""
return hmac.compare_digest(secret, self.secret)
return hash.verify(self.secret, secret)


class BaseMechanism(metaclass=ABCMeta):
Expand Down
3 changes: 2 additions & 1 deletion pysasl/crammd5.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from . import (ServerMechanism, ClientMechanism, ServerChallenge,
ChallengeResponse, AuthenticationError, UnexpectedChallenge,
AuthenticationCredentials)
from .hashing import HashInterface

try:
from passlib.utils import saslprep # type: ignore
Expand Down Expand Up @@ -57,7 +58,7 @@ def secret(self) -> str:
"""
raise AttributeError('secret')

def check_secret(self, secret: str) -> bool:
def check_secret(self, secret: str, *, hash: HashInterface = None) -> bool:
secret_b = saslprep(secret).encode('utf-8')
expected_hmac = hmac.new(secret_b, self.challenge, hashlib.md5)
expected = expected_hmac.hexdigest().encode('ascii')
Expand Down
3 changes: 2 additions & 1 deletion pysasl/external.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from . import (ServerMechanism, ClientMechanism, ServerChallenge,
ChallengeResponse, AuthenticationCredentials,
UnexpectedChallenge)
from .hashing import HashInterface

__all__ = ['ExternalResult', 'ExternalMechanism']

Expand Down Expand Up @@ -41,7 +42,7 @@ def secret(self) -> str:
"""
raise AttributeError('secret')

def check_secret(self, secret: str) -> bool:
def check_secret(self, secret: str, *, hash: HashInterface = None) -> bool:
"""This method always returns True for this mechanism, unless
overridden by a subclass to provide external enforcement rules.
Expand Down
126 changes: 126 additions & 0 deletions pysasl/hashing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Provides an abstraction and several implementations for the ability to hash
and verify secrets.
"""

import os
import hashlib
import secrets
import warnings
from abc import abstractmethod
from typing_extensions import Protocol

try:
from passlib.context import CryptContext # type: ignore
from passlib.apps import custom_app_context # type: ignore
except ImportError: # pragma: no cover
CryptContext = None
custom_app_context = None
_has_passlib = False
else:
_has_passlib = True

__all__ = ['HashInterface', 'BuiltinHash', 'Cleartext', 'get_hash']


class HashInterface(Protocol):
"""Defines a basic interface for hash implementations. This is specifically
designed to be compatible with :mod:`passlib` hashes.
"""

@abstractmethod
def hash(self, value: str) -> str:
"""Hash the *value* and return the digest.
Args:
value: The string to hash.
"""
...

@abstractmethod
def verify(self, value: str, digest: str) -> bool:
"""Check the *value* against the given *digest*.
Args:
value: The string to check.
digest: The hashed digest string.
"""
...


class BuiltinHash(HashInterface):
"""Implements :class:`HashInterface` using the :func:`hashlib.pbkdf2_hmac`
function and random salt..
Args:
hash_name: The hash name.
salt_len: The length of the random salt.
rounds: The number of hash rounds.
"""

def __init__(self, *, hash_name: str = 'sha256', salt_len: int = 16,
rounds: int = 1000000) -> None:
super().__init__()
self.hash_name = hash_name
self.salt_len = salt_len
self.rounds = rounds

def _hash(self, value: str, salt: bytes) -> bytes:
value_b = value.encode('utf-8')
hashed = hashlib.pbkdf2_hmac(
self.hash_name, value_b, salt, self.rounds)
return salt + hashed

def hash(self, value: str) -> str:
salt = os.urandom(self.salt_len)
return self._hash(value, salt).hex()

def verify(self, value: str, digest: str) -> bool:
digest_b = bytes.fromhex(digest)
salt = digest_b[0:self.salt_len]
value_hashed = self._hash(value, salt)
return secrets.compare_digest(value_hashed, digest_b)


class Cleartext(HashInterface):
"""Implements :class:`HashInterface` with no hashing performed."""

def hash(self, value: str) -> str:
return value

def verify(self, value: str, digest: str) -> bool:
return secrets.compare_digest(value, digest)

def __repr__(self) -> str:
return 'Cleartext()'


def get_hash(*, no_passlib: bool = False,
passlib_config: str = None) -> HashInterface: # pragma: no cover
"""Provide a secure, default :class:`HashInterface` implementation.
If :mod:`passlib` is not available, a custom hash is always used based on
:func:`hashlib.pbkdf2_hmac`. The *passlib_config* parameter is ignored.
If :mod:`passlib` is available, a :class:`~passlib.context.CryptContext` is
loaded from the *config_file* parameter. If *config_file* is ``None``, then
:attr:`passlib.apps.custom_app_context` is returned.
Args:
no_passlib: If true, do not use :mod:`passlib` even if available.
config_file: A passlib config file.
"""
if no_passlib or not _has_passlib:
if passlib_config:
warnings.warn('passlib not available, '
'ignoring passlib_config argument')
return BuiltinHash()
elif passlib_config is not None:
return CryptContext.from_path(passlib_config)
else:
return custom_app_context
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
license = fh.read()

setup(name='pysasl',
version='0.5.0',
version='0.6.0',
author='Ian Good',
author_email='icgood@gmail.com',
description='Pure Python SASL client and server library.',
Expand All @@ -39,6 +39,7 @@
python_requires='~=3.6',
include_package_data=True,
packages=find_packages(),
install_requiers=['typing-extensions'],
extras_require={'passlib': ['passlib']},
entry_points={'pysasl.mechanisms': [
'crammd5 = pysasl.crammd5:CramMD5Mechanism',
Expand Down
30 changes: 30 additions & 0 deletions test/test_hashing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@

from __future__ import absolute_import

import unittest

from pysasl import AuthenticationCredentials
from pysasl.hashing import BuiltinHash

builtin_hash = BuiltinHash(rounds=1000)
password_hash = '6f3b2db13d217e79d70d43d326a6e485756bcbe1b4e959f3e86c0d9eb62' \
'fa40a352c178b1fc30896e7c484d74a78561d'


class TestHashing(unittest.TestCase):

def test_builtin_good(self) -> None:
creds = AuthenticationCredentials('username', 'password')
self.assertTrue(creds.check_secret(password_hash, hash=builtin_hash))

def test_builtin_invalid(self) -> None:
creds = AuthenticationCredentials('username', 'invalid')
self.assertFalse(creds.check_secret(password_hash, hash=builtin_hash))

def test_cleartext_good(self) -> None:
creds = AuthenticationCredentials('username', 'password')
self.assertTrue(creds.check_secret('password'))

def test_cleartext_invalid(self) -> None:
creds = AuthenticationCredentials('username', 'invalid')
self.assertFalse(creds.check_secret('password'))

0 comments on commit e5b24eb

Please sign in to comment.