From 4df62c3081c19709b8303565d0bd0ac39aa7f91a Mon Sep 17 00:00:00 2001 From: Eli Collins Date: Sat, 28 Apr 2012 11:37:41 -0400 Subject: added some missing tests; fixed a bunch of bugs --- passlib/context.py | 7 ++++- passlib/ext/django/models.py | 2 +- passlib/ext/django/utils.py | 10 +++---- passlib/handlers/sha1_crypt.py | 6 +++-- passlib/ifc.py | 6 ++--- passlib/registry.py | 2 +- passlib/tests/test_context.py | 53 +++++++++++++++++++++++++++++++++++--- passlib/tests/test_ext_django.py | 51 ++++++++++++++++++++++++++---------- passlib/tests/test_handlers.py | 20 ++++++++++++-- passlib/tests/test_hosts.py | 3 +++ passlib/tests/test_registry.py | 4 +-- passlib/tests/test_utils_crypto.py | 6 +++-- passlib/tests/utils.py | 9 +++++-- passlib/utils/_blowfish/base.py | 1 + passlib/utils/compat.py | 8 +++--- passlib/utils/md4.py | 3 +-- passlib/utils/pbkdf2.py | 47 +++++++++++++++------------------ 17 files changed, 167 insertions(+), 71 deletions(-) diff --git a/passlib/context.py b/passlib/context.py index e64ffc7..8fb8006 100644 --- a/passlib/context.py +++ b/passlib/context.py @@ -1495,7 +1495,12 @@ class CryptContext(object): for scheme in value: if not isinstance(scheme, str): raise ExpectedTypeError(value, "str", "deprecated element") - if scheme not in schemes and scheme != "auto": + if scheme in schemes: + continue + elif scheme == "auto": + if len(value) > 1: + raise ValueError("cannot list other schemes if ``deprecated=['auto']`` is used") + else: raise KeyError("deprecated scheme not found " "in policy: %r" % (scheme,)) # TODO: make sure there's at least one non-deprecated scheme. diff --git a/passlib/ext/django/models.py b/passlib/ext/django/models.py index 77d1443..eb82143 100644 --- a/passlib/ext/django/models.py +++ b/passlib/ext/django/models.py @@ -75,7 +75,7 @@ def _apply_patch(): return (encoded is not None and encoded != UNUSABLE_PASSWORD) def is_valid_secret(secret): - return password is not None + return secret is not None else: has_hashers = True diff --git a/passlib/ext/django/utils.py b/passlib/ext/django/utils.py index 4358ab3..72e50ae 100644 --- a/passlib/ext/django/utils.py +++ b/passlib/ext/django/utils.py @@ -18,7 +18,7 @@ from passlib.context import CryptContext from passlib.exc import PasslibRuntimeWarning from passlib.registry import get_crypt_handler, list_crypt_handlers from passlib.utils import classproperty -from passlib.utils.compat import bytes, method_function_attr, iteritems +from passlib.utils.compat import bytes, get_method_function, iteritems # local __all__ = [ "get_preset_config", @@ -205,7 +205,7 @@ def get_passlib_hasher(handler): if hasattr(handler, "django_name"): # return native hasher instance # XXX: should cache this too. - return _get_hasher(handler.django_name)() + return _get_hasher(handler.django_name) if handler.name == "django_disabled": raise ValueError("can't wrap unusable-password handler") try: @@ -359,11 +359,7 @@ class _PatchManager(object): @staticmethod def _is_same_value(left, right): "check if two values are the same (stripping method wrappers, etc)" - def resolve(value): - if hasattr(value, method_function_attr): - return getattr(value, method_function_attr) - return value - return resolve(left) == resolve(right) + return get_method_function(left) == get_method_function(right) #=================================================================== # reading diff --git a/passlib/handlers/sha1_crypt.py b/passlib/handlers/sha1_crypt.py index 89145a7..852b493 100644 --- a/passlib/handlers/sha1_crypt.py +++ b/passlib/handlers/sha1_crypt.py @@ -15,7 +15,7 @@ from warnings import warn #libs from passlib.utils import classproperty, h64, safe_crypt, test_crypt from passlib.utils.compat import b, bytes, u, uascii_to_str, unicode -from passlib.utils.pbkdf2 import hmac_sha1 +from passlib.utils.pbkdf2 import get_prf import passlib.utils.handlers as uh #pkg #local @@ -24,6 +24,8 @@ __all__ = [ #========================================================= #sha1-crypt #========================================================= +_hmac_sha1 = get_prf("hmac-sha1")[0] + class sha1_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler): """This class implements the SHA1-Crypt password hash, and follows the :ref:`password-hash-api`. @@ -104,7 +106,7 @@ class sha1_crypt(uh.HasManyBackends, uh.HasRounds, uh.HasSalt, uh.GenericHandler result = result.encode("ascii") r = 0 while r < rounds: - result = hmac_sha1(secret, result) + result = _hmac_sha1(secret, result) r += 1 return h64.encode_transposed_bytes(result, self._chk_offsets).decode("ascii") diff --git a/passlib/ifc.py b/passlib/ifc.py index 91f5feb..8d9d49f 100644 --- a/passlib/ifc.py +++ b/passlib/ifc.py @@ -52,9 +52,9 @@ class PasswordHash(object): #---------------------------------- # general information #---------------------------------- - name = abstractproperty() - setting_kwds = abstractproperty() - context_kwds = abstractproperty() + ##name + ##setting_kwds + ##context_kwds #---------------------------------- # salt information -- if 'salt' in setting_kwds diff --git a/passlib/registry.py b/passlib/registry.py index 1eab7f3..68109cc 100644 --- a/passlib/registry.py +++ b/passlib/registry.py @@ -220,7 +220,7 @@ def register_crypt_handler_path(name, path): if ':' in path: if path.count(':') > 1: raise ValueError("path cannot have more than one ':'") - if path.index('.', path.index(':')) > -1: + if path.find('.', path.index(':')) > -1: raise ValueError("path cannot have '.' to right of ':'") # store location diff --git a/passlib/tests/test_context.py b/passlib/tests/test_context.py index 598457d..b80434c 100644 --- a/passlib/tests/test_context.py +++ b/passlib/tests/test_context.py @@ -25,7 +25,7 @@ from passlib import hash from passlib.context import CryptContext, LazyCryptContext from passlib.exc import PasslibConfigWarning from passlib.utils import tick, to_bytes, to_unicode -from passlib.utils.compat import irange, u +from passlib.utils.compat import irange, u, unicode, str_to_uascii import passlib.utils.handlers as uh from passlib.tests.utils import TestCase, mktemp, catch_warnings, \ gae_env, set_file @@ -641,7 +641,11 @@ sha512_crypt__min_rounds = 45000 # name not in schemes self.assertRaises(KeyError, ctx.handler, "mysql323") - # TODO: per-category + # check handler() honors category default + ctx = CryptContext("sha256_crypt,md5_crypt", admin__context__default="md5_crypt") + self.assertEqual(ctx.handler(), hash.sha256_crypt) + self.assertEqual(ctx.handler(category="staff"), hash.sha256_crypt) + self.assertEqual(ctx.handler(category="admin"), hash.md5_crypt) def test_33_options(self): "test internal _get_record_options() method" @@ -957,7 +961,50 @@ sha512_crypt__min_rounds = 45000 self.assertFalse(cc.needs_update('$5$rounds=3000$fS9iazEwTKi7QPW4$VasgBC8FqlOvD7x2HhABaMXCTh9jwHclPA9j5YQdns.')) self.assertTrue(cc.needs_update('$5$rounds=3001$QlFHHifXvpFX4PLs$/0ekt7lSs/lOikSerQ0M/1porEHxYq7W/2hdFpxA3fA')) - # TODO: check with secret passed in, that _bind_needs_update() is invoked correctly. + #-------------------------------------------------------------- + # test _bind_needs_update() framework + #-------------------------------------------------------------- + bind_state = [] + check_state = [] + class dummy(uh.StaticHandler): + name = 'dummy' + _hash_prefix = '@' + + @classmethod + def _bind_needs_update(cls, **settings): + bind_state.append(settings) + return cls._needs_update + + @classmethod + def _needs_update(cls, hash, secret): + check_state.append((hash,secret)) + return secret == "nu" + + def _calc_checksum(self, secret): + from hashlib import md5 + if isinstance(secret, unicode): + secret = secret.encode("utf-8") + return str_to_uascii(md5(secret).hexdigest()) + + # creating context should call bind function w/ settings + ctx = CryptContext([dummy]) + self.assertEqual(bind_state, [{}]) + + # calling needs_update should query callback + hash = dummy.encrypt("test") + self.assertFalse(ctx.needs_update(hash)) + self.assertEqual(check_state, [(hash,None)]) + del check_state[:] + + # now with a password + self.assertFalse(ctx.needs_update(hash, secret='bob')) + self.assertEqual(check_state, [(hash,'bob')]) + del check_state[:] + + # now when it returns True + self.assertTrue(ctx.needs_update(hash, secret='nu')) + self.assertEqual(check_state, [(hash,'nu')]) + del check_state[:] #-------------------------------------------------------------- # border cases diff --git a/passlib/tests/test_ext_django.py b/passlib/tests/test_ext_django.py index 96b7c13..998fa63 100644 --- a/passlib/tests/test_ext_django.py +++ b/passlib/tests/test_ext_django.py @@ -11,7 +11,8 @@ import warnings # pkg from passlib.apps import django10_context, django14_context from passlib.context import CryptContext -from passlib.utils.compat import iteritems, unicode, method_function_attr +import passlib.exc as exc +from passlib.utils.compat import iteritems, unicode, get_method_function, u, PY3 from passlib.utils import memoized_property from passlib.registry import get_crypt_handler # tests @@ -22,9 +23,14 @@ from passlib.tests.test_handlers import get_handler_case #========================================================= # configure django settings for testcases #========================================================= +from passlib.ext.django.utils import DJANGO_VERSION + +# disable all Django integration tests under py3, +# since Django doesn't support py3 yet. +if PY3 and DJANGO_VERSION < (1,5): + DJANGO_VERSION = () # convert django version to some cheap flags -from passlib.ext.django.utils import DJANGO_VERSION has_django = bool(DJANGO_VERSION) has_django0 = has_django and DJANGO_VERSION < (1,0) has_django1 = DJANGO_VERSION >= (1,0) @@ -110,7 +116,7 @@ def iter_patch_candidates(): if attr.startswith("_"): continue value = getattr(obj, attr) - value = getattr(value, method_function_attr, value) + value = get_method_function(value) source = getattr(value, "__module__", None) if source: yield obj, attr, source @@ -129,6 +135,9 @@ def create_mock_setter(): setter.popstate = popstate return setter +def has_backend(handler): + return not hasattr(handler, "has_backend") or handler.has_backend() + #========================================================= # sample config used by basic tests #========================================================= @@ -500,9 +509,9 @@ class DjangoExtensionTest(TestCase, _ExtensionSupport): "v2RWkZQzctPdejyRqmmTDQpZN6wTh7.RUy9zF2LftT6") self.assertEqual(hasher.safe_summary(encoded), {'algorithm': 'sha256_crypt', - 'salt': u'abcdab**********', + 'salt': u('abcdab**********'), 'iterations': 1234, - 'hash': u'v2RWkZ*************************************', + 'hash': u('v2RWkZ*************************************'), }) #========================================================= @@ -580,10 +589,13 @@ class DjangoExtensionTest(TestCase, _ExtensionSupport): user = FakeUser() user.set_password("stub") default = context.handler() - self.assertTrue(default.verify("stub", user.password)) + if has_backend(default): + self.assertTrue(default.verify("stub", user.password)) + else: + self.assertRaises(exc.MissingBackendError, default.verify, 'stub', user.password) # test module-level make_password - if has_django14: + if has_backend(default) and has_django14: hash = hashers.make_password('stub') self.assertTrue(default.verify('stub', hash)) @@ -591,10 +603,17 @@ class DjangoExtensionTest(TestCase, _ExtensionSupport): for scheme in context.schemes(): deprecated = context._is_deprecated_scheme(scheme) assert not (deprecated and scheme == default.name) - testcase = get_handler_case(scheme) + try: + testcase = get_handler_case(scheme) + except exc.MissingBackendError: + assert scheme == "bcrypt" + continue if testcase.is_disabled_handler: continue handler = testcase.handler + if not has_backend(handler): + assert scheme == "django_bcrypt" + continue for secret, hash in testcase.iter_known_hashes(): ## print [scheme, secret, hash, deprecated, scheme==default.name] other = 'stub' @@ -621,7 +640,8 @@ class DjangoExtensionTest(TestCase, _ExtensionSupport): self.assertTrue(user.check_password(secret)) # check if it upgraded the hash - if deprecated: + needs_update = context.needs_update(hash) + if needs_update: self.assertNotEqual(user.password, hash) self.assertFalse(handler.identify(user.password)) self.assertTrue(default.identify(user.password)) @@ -629,10 +649,15 @@ class DjangoExtensionTest(TestCase, _ExtensionSupport): self.assertEqual(user.password, hash) # test module-level check_password - self.assertTrue(check_password(secret, hash, setter=setter)) - self.assertEqual(setter.popstate(), [secret] if deprecated else []) - self.assertFalse(check_password(other, hash, setter=setter)) - self.assertEqual(setter.popstate(), []) + if has_django14 or patched: + self.assertTrue(check_password(secret, hash, setter=setter)) + self.assertEqual(setter.popstate(), [secret] if needs_update else []) + self.assertFalse(check_password(other, hash, setter=setter)) + self.assertEqual(setter.popstate(), []) + elif scheme != "hex_md5": + # django 1.3 never called check_password() for hex_md5 + self.assertTrue(check_password(secret, hash)) + self.assertFalse(check_password(other, hash)) # test module-level identify_hasher if has_django14 and patched: diff --git a/passlib/tests/test_handlers.py b/passlib/tests/test_handlers.py index 206a089..6eca889 100644 --- a/passlib/tests/test_handlers.py +++ b/passlib/tests/test_handlers.py @@ -11,7 +11,7 @@ import warnings #pkg from passlib import hash from passlib.utils import repeat_string -from passlib.utils.compat import irange, PY3, u +from passlib.utils.compat import irange, PY3, u, get_method_function from passlib.tests.utils import TestCase, HandlerCase, create_backend_case, \ enable_option, b, catch_warnings, UserHandlerMixin, randintgauss #module @@ -70,6 +70,7 @@ class _bcrypt_test(HandlerCase): "base for BCrypt test cases" handler = hash.bcrypt secret_size = 72 + reduce_default_rounds = True known_correct_hashes = [ # @@ -826,6 +827,19 @@ class django_salted_md5_test(HandlerCase, _DjangoHelper): 'md5$aa$bb', ] + def get_fuzz_salt_size(self): + # workaround for django14 regression -- + # 1.4 won't accept hashes with empty salt strings, unlike 1.3 and earlier. + # looks to be fixed in a future release -- https://code.djangoproject.com/ticket/18144 + # for now, we avoid salt_size==0 under 1.4 + handler = self.handler + from passlib.tests.test_ext_django import has_django14 + default = handler.default_salt_size + assert handler.min_salt_size == 0 + lower = 1 if has_django14 else 0 + upper = handler.max_salt_size or default*4 + return randintgauss(lower, upper, default, default*.5) + class django_salted_sha1_test(HandlerCase, _DjangoHelper): "test django_salted_sha1" handler = hash.django_salted_sha1 @@ -854,6 +868,8 @@ class django_salted_sha1_test(HandlerCase, _DjangoHelper): 'sha1$c2e86$0f75', ] + get_fuzz_salt_size = get_method_function(django_salted_md5_test.get_fuzz_salt_size) + class django_pbkdf2_sha256_test(HandlerCase, _DjangoHelper): "test django_pbkdf2_sha256" handler = hash.django_pbkdf2_sha256 @@ -1022,7 +1038,7 @@ class hex_md4_test(HandlerCase): (UPASS_TABLE, '876078368c47817ce5f9115f3a42cf74'), ] -class hex_md5_test(HandlerCase, _DjangoHelper): +class hex_md5_test(HandlerCase): handler = hash.hex_md5 known_correct_hashes = [ ("password", '5f4dcc3b5aa765d61d8327deb882cf99'), diff --git a/passlib/tests/test_hosts.py b/passlib/tests/test_hosts.py index a64fb30..69408da 100644 --- a/passlib/tests/test_hosts.py +++ b/passlib/tests/test_hosts.py @@ -5,6 +5,7 @@ from __future__ import with_statement #core import logging; log = logging.getLogger(__name__) +import warnings #site #pkg from passlib import hosts, hash as hashmod @@ -48,6 +49,8 @@ class HostsTest(TestCase): self.check_unix_disabled(ctx) def test_bsd_contexts(self): + warnings.filterwarnings("ignore", + "SECURITY WARNING: .*pure-python bcrypt.*") for ctx in [ hosts.freebsd_context, hosts.openbsd_context, diff --git a/passlib/tests/test_registry.py b/passlib/tests/test_registry.py index ce9466d..c076845 100644 --- a/passlib/tests/test_registry.py +++ b/passlib/tests/test_registry.py @@ -82,7 +82,7 @@ class RegistryTest(TestCase): def test_register_crypt_handler_path(self): "test register_crypt_handler_path()" #NOTE: this messes w/ internals of registry, shouldn't be used publically. - paths = registry._handler_locations + paths = registry._locations #check namespace is clear self.assertTrue('dummy_0' not in paths) @@ -156,7 +156,7 @@ class RegistryTest(TestCase): name = "dummy_1" self.assertRaises(KeyError, get_crypt_handler, "dummy_1") - self.asssertIs(get_crypt_handler("dummy_1", None), None) + self.assertIs(get_crypt_handler("dummy_1", None), None) register_crypt_handler(dummy_1) self.assertIs(get_crypt_handler("dummy_1"), dummy_1) diff --git a/passlib/tests/test_utils_crypto.py b/passlib/tests/test_utils_crypto.py index 94c20e8..5b50d41 100644 --- a/passlib/tests/test_utils_crypto.py +++ b/passlib/tests/test_utils_crypto.py @@ -285,7 +285,6 @@ import hashlib import hmac from passlib.utils import pbkdf2 -#TODO: should we bother testing hmac_sha1() function? it's verified via sha1_crypt testing. class CryptoTest(TestCase): "test various crypto functions" @@ -490,8 +489,11 @@ class _Pbkdf2BackendTest(TestCase): def test_hmac_sha1(self): "test independant hmac_sha1() method" + # TODO: expand this into a full test of get_prf() + hmac_sha1, size = pbkdf2.get_prf("hmac-sha1") + self.assertEqual(size, 20) self.assertEqual( - pbkdf2.hmac_sha1(b("secret"), b("salt")), + hmac_sha1(b("secret"), b("salt")), b('\xfc\xd4\x0c;]\r\x97\xc6\xf1S\x8d\x93\xb9\xeb\xc6\x00\x04.\x8b\xfe') ) diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py index 35a7b50..77e8933 100644 --- a/passlib/tests/utils.py +++ b/passlib/tests/utils.py @@ -1739,8 +1739,13 @@ class HandlerCase(TestCase): def mangle_fuzz_password(self, secret): "mangle fuzz-testing password so it doesn't match" - secret = secret.strip()[1:] - return secret or self.get_fuzz_password() + other = secret.strip()[1:] + if other: + return other + while True: + other = self.get_fuzz_password() + if other != secret: + return other def get_fuzz_password_pair(self): "generate random password, and non-matching alternate password" diff --git a/passlib/utils/_blowfish/base.py b/passlib/utils/_blowfish/base.py index fdbead1..e725e7f 100644 --- a/passlib/utils/_blowfish/base.py +++ b/passlib/utils/_blowfish/base.py @@ -5,6 +5,7 @@ #core import struct #pkg +from passlib.utils.compat import bytes from passlib.utils import repeat_string #local __all__ = [ diff --git a/passlib/utils/compat.py b/passlib/utils/compat.py index 4f75c4a..71f41a7 100644 --- a/passlib/utils/compat.py +++ b/passlib/utils/compat.py @@ -266,12 +266,12 @@ def exc_err(): if PY3: method_function_attr = "__func__" - def get_method_function(method): - return method.__func__ else: method_function_attr = "im_func" - def get_method_function(method): - return method.im_func + +def get_method_function(func): + "given (potential) method, return underlying function" + return getattr(func, method_function_attr, func) #============================================================================= # input/output diff --git a/passlib/utils/md4.py b/passlib/utils/md4.py index 4389a09..2dd6da9 100644 --- a/passlib/utils/md4.py +++ b/passlib/utils/md4.py @@ -248,8 +248,7 @@ def _has_native_md4(): if result == '31d6cfe0d16ae931b73c59d7e0c089c0': return True if PYPY and result == '': - # as of pypy 1.5-1.7, this returns empty string! - # since this is expected, don't bother w/ warning. + # workaround for https://bugs.pypy.org/issue957, fixed in PyPy 1.8 return False #anything else should alert user from passlib.exc import PasslibRuntimeWarning diff --git a/passlib/utils/pbkdf2.py b/passlib/utils/pbkdf2.py index 9c7be9e..be77e39 100644 --- a/passlib/utils/pbkdf2.py +++ b/passlib/utils/pbkdf2.py @@ -8,7 +8,6 @@ maybe rename to "kdf" since it's getting more key derivation functions added. #================================================================================= #core import hashlib -from hmac import trans_36, trans_5C import logging; log = logging.getLogger(__name__) import re from struct import pack @@ -20,11 +19,10 @@ except ImportError: _EVP = None #pkg from passlib.exc import PasslibRuntimeWarning -from passlib.utils import join_bytes, to_native_str, bytes_to_int, int_to_bytes +from passlib.utils import join_bytes, to_native_str, bytes_to_int, int_to_bytes, join_byte_values from passlib.utils.compat import b, bytes, BytesIO, irange, callable, int_types #local __all__ = [ - "hmac_sha1", "get_prf", "pbkdf1", "pbkdf2", @@ -132,30 +130,14 @@ def norm_hash_name(name, format="hashlib"): row = _nhn_cache[orig] = (name2, name) return row[idx] -#================================================================================= -#quick hmac_sha1 implementation used various places -#================================================================================= -def hmac_sha1(key, msg): - "perform raw hmac-sha1 of a message" - return hmac.new(key, msg, hashlib.sha1).digest() - -if _EVP: - #default *should* be sha1, which saves us a wrapper function, but might as well check. - try: - result = _EVP.hmac(b('x'),b('y')) - except ValueError: #pragma: no cover - #this is probably not a good sign if it happens. - from passlib.exc import PasslibRuntimeWarning - warn("Passlib: M2Crypt.EVP.hmac() unexpected threw value error during " - "passlib startup test", PasslibRuntimeWarning) - else: - if result == b(',\x1cb\xe0H\xa5\x82M\xfb>\xd6\x98\xef\x8e\xf9oQ\x85\xa3i'): - hmac_sha1 = _EVP.hmac - #================================================================================= #general prf lookup #================================================================================= _BNULL = b('\x00') +_XY_DIGEST = b(',\x1cb\xe0H\xa5\x82M\xfb>\xd6\x98\xef\x8e\xf9oQ\x85\xa3i') + +_trans_5C = join_byte_values((x ^ 0x5C) for x in irange(256)) +_trans_36 = join_byte_values((x ^ 0x36) for x in irange(256)) def _get_hmac_prf(digest): "helper to return HMAC prf for specific digest" @@ -165,7 +147,20 @@ def _get_hmac_prf(digest): " generated by passlib.utils.pbkdf2.get_prf()" % digest) - if _EVP: + if _EVP and digest == "sha1": + # use m2crypto function directly for sha1, since that's it's default digest + try: + result = _EVP.hmac(b('x'),b('y')) + except ValueError: #pragma: no cover + pass + else: + if result == _XY_DIGEST: + return _EVP.hmac, 20 + # pragma: no cover + # don't expect to ever get here, but will fall back to pure-python if we do. + warn("M2Crypto.EVP.HMAC() returned unexpected result " + "during Passlib self-test!", PasslibRuntimeWarning) + elif _EVP: # use m2crypto if it's present and supports requested digest try: result = _EVP.hmac(b('x'), b('y'), digest) @@ -194,8 +189,8 @@ def _get_hmac_prf(digest): if len(key) > block_size: key = digest_const(key).digest() key += _BNULL * (block_size - len(key)) - tmp = digest_const(key.translate(trans_36) + msg).digest() - return digest_const(key.translate(trans_5C) + tmp).digest() + tmp = digest_const(key.translate(_trans_36) + msg).digest() + return digest_const(key.translate(_trans_5C) + tmp).digest() tag_wrapper(prf) return prf, digest_size -- cgit v1.2.1