diff options
-rw-r--r-- | CHANGES | 15 | ||||
-rw-r--r-- | docs/lib/passlib.hash.bcrypt.rst | 15 | ||||
-rw-r--r-- | docs/lib/passlib.hash.bcrypt_sha256.rst | 71 | ||||
-rw-r--r-- | docs/lib/passlib.hash.rst | 3 | ||||
-rw-r--r-- | docs/modular_crypt_format.rst | 3 | ||||
-rw-r--r-- | passlib/handlers/bcrypt.py | 125 | ||||
-rw-r--r-- | passlib/registry.py | 1 | ||||
-rw-r--r-- | passlib/tests/test_handlers_bcrypt.py | 108 | ||||
-rw-r--r-- | passlib/tests/test_handlers_django.py | 2 | ||||
-rw-r--r-- | passlib/tests/test_registry.py | 11 | ||||
-rw-r--r-- | passlib/tests/utils.py | 12 | ||||
-rw-r--r-- | passlib/utils/_blowfish/__init__.py | 12 | ||||
-rw-r--r-- | passlib/utils/_blowfish/base.py | 6 | ||||
-rw-r--r-- | passlib/utils/handlers.py | 12 |
14 files changed, 355 insertions, 41 deletions
@@ -7,14 +7,21 @@ Release History **1.6.2** (NOT YET RELEASED) ============================ - * Updated the :attr:`~passlib.ifc.PasswordHash.default_rounds` values for all of the hashes. + Minor changes & compatibility fixes - * *BCrypt*: Added support for the `bcrypt <https://pypi.python.org/pypi/bcrypt>`_ + * Re-tuned the :attr:`~passlib.ifc.PasswordHash.default_rounds` values for all of the hashes. + + * *New hash:* Added the :doc:`bcrypt_sha256 <lib/passlib.hash.bcrypt_sha256>` hash, + which wraps BCrypt using SHA256 in order to work around it's password size limitations + (:issue:`43`). + + * :doc:`passlib.hash.bcrypt <lib/passlib.hash.bcrypt>`: + Added support for the `bcrypt <https://pypi.python.org/pypi/bcrypt>`_ library as of the possible bcrypt backends that will be used if available. (:issue:`49`) - * *Django*: Passlib's Django extension (:mod:`passlib.ext.django`), - and it's related hashes and unittests, have been updated to handle + * :mod:`passlib.ext.django`: Passlib's Django extension + (and it's related hashes and unittests) have been updated to handle some minor API changes in Django 1.5-1.6. They should now be compatible with Django 1.2 and up. (:issue:`50`) diff --git a/docs/lib/passlib.hash.bcrypt.rst b/docs/lib/passlib.hash.bcrypt.rst index b94dfa8..eafe7ac 100644 --- a/docs/lib/passlib.hash.bcrypt.rst +++ b/docs/lib/passlib.hash.bcrypt.rst @@ -93,6 +93,21 @@ While BCrypt's basic algorithm is described in it's design document [#f1]_, the OpenBSD implementation [#f2]_ is considered the canonical reference, even though it differs from the design document in a few small ways. +Security Issues +=============== + +.. _bcrypt-password-truncation: + +* Password Truncation. + + While not a security issue per-se, bcrypt does have one major limitation: + password are truncated on the first NULL byte (if any), + and only the first 72 bytes of a password are hashed... all the rest are ignored. + Furthermore, bytes 55-72 are not fully mixed into the resulting hash (citation needed!). + To work around both these issues, many applications first run the password through a message + digest such as SHA2-256. Passlib offers the premade :doc:`passlib.hash.bcrypt_sha256` + to take care of this issue. + Deviations ========== This implementation of bcrypt differs from others in a few ways: diff --git a/docs/lib/passlib.hash.bcrypt_sha256.rst b/docs/lib/passlib.hash.bcrypt_sha256.rst new file mode 100644 index 0000000..22420db --- /dev/null +++ b/docs/lib/passlib.hash.bcrypt_sha256.rst @@ -0,0 +1,71 @@ +================================================================== +:class:`passlib.hash.bcrypt_sha256` - BCrypt+SHA256 +================================================================== + +.. versionadded:: 1.6.2 + +.. currentmodule:: passlib.hash + +BCrypt was developed to replace :class:`~passlib.hash.md5_crypt` for BSD systems. +It uses a modified version of the Blowfish stream cipher. +It does, however, truncate passwords to 72 bytes, and some other minor quirks +(see :ref:`BCrypt Password Truncation <bcrypt-password-truncation>` for details). +This class works around that issue by first running the password through SHA2-256. +This class can be used directly as follows:: + + >>> from passlib.hash import bcrypt_sha256 + + >>> # generate new salt, encrypt password + >>> h = bcrypt_sha256.encrypt("password") + >>> h + '$bcrypt-sha256$2a,12$LrmaIX5x4TRtAwEfwJZa1.$2ehnw6LvuIUTM0iz4iz9hTxv21B6KFO' + + >>> # the same, but with an explicit number of rounds + >>> bcrypt.encrypt("password", rounds=8) + '$bcrypt-sha256$2a,8$UE3dIZ.0I6XZtA/LdMrrle$Ag04/5zYu./12.OSqInXZnJ.WZoh1ua' + + >>> # verify password + >>> bcrypt.verify("password", h) + True + >>> bcrypt.verify("wrong", h) + False + +.. note:: + + It is strongly recommended that you install + `bcrypt <https://pypi.python.org/pypi/bcrypt>`_ + or `py-bcrypt <https://pypi.python.org/pypi/py-bcrypt>`_ + when using this hash. See :doc:`passlib.hash.bcrypt` for more details. + +Interface +========= +.. autoclass:: bcrypt_sha256() + +Format +====== +Bcrypt-SHA256 is compatible with the :ref:`modular-crypt-format`, and uses ``$bcrypt-sha256$`` as the identifying prefix +for all it's strings. +An example hash (of ``password``) is: + + ``$bcrypt-sha256$2a,12$LrmaIX5x4TRtAwEfwJZa1.$2ehnw6LvuIUTM0iz4iz9hTxv21B6KFO`` + +Bcrypt-SHA256 hashes have the format :samp:`$bcrypt-sha256${variant},{rounds}${salt}${checksum}`, where: + +* :samp:`{variant}` is the BCrypt variant in use (usually, as in this case, ``2a``). +* :samp:`{rounds}` is a cost parameter, encoded as decimal integer, + which determines the number of iterations used via :samp:`{iterations}=2**{rounds}` (rounds is 12 in the example). +* :samp:`{salt}` is a 22 character salt string, using the characters in the regexp range ``[./A-Za-z0-9]`` (``LrmaIX5x4TRtAwEfwJZa1.`` in the example). +* :samp:`{checksum}` is a 31 character checksum, using the same characters as the salt (``2ehnw6LvuIUTM0iz4iz9hTxv21B6KFO`` in the example). + +Algorithm +========= +The algorithm this hash uses is as follows: + +* first the password is encoded to ``UTF-8`` if not already encoded. +* then it's run through SHA2-256 to generate a 32 byte digest. +* this is encoded using base64, resulting in a 44-byte result + (including the trailing padding ``=``). For the example ``"password"``, + the output from this stage would be ``"XohImNooBHFR0OVvjcYpJ3NgPQ1qq73WKhHvch0VQtg="``. +* this base64 string is then passed on to the underlying bcrypt algorithm + as the new password to be hashed. See :doc:`passlib.hash.bcrypt` for details + on it's operation. diff --git a/docs/lib/passlib.hash.rst b/docs/lib/passlib.hash.rst index 906f301..8817f74 100644 --- a/docs/lib/passlib.hash.rst +++ b/docs/lib/passlib.hash.rst @@ -10,7 +10,7 @@ Overview The :mod:`!passlib.hash` module contains all the password hash algorithms built into Passlib. While each hash has it's own options and output format, they all share a common interface, documented in detail in the :ref:`password-hash-api`. The following pages -describe the common interface, and then describe each hash in detail +describe the common interface, and then describe each hash in detail (including it's format, underlying algorithm, and known security issues). .. seealso:: :doc:`Quickstart Guide </new_app_quickstart>` -- advice on @@ -123,6 +123,7 @@ they can be used compatibly along side other modular crypt format hashes. :maxdepth: 1 passlib.hash.apr_md5_crypt + passlib.hash.bcrypt_sha256 passlib.hash.phpass passlib.hash.pbkdf2_digest passlib.hash.cta_pbkdf2_sha1 diff --git a/docs/modular_crypt_format.rst b/docs/modular_crypt_format.rst index 55e9aa3..a0f0cc0 100644 --- a/docs/modular_crypt_format.rst +++ b/docs/modular_crypt_format.rst @@ -159,7 +159,7 @@ by the following operating systems and platforms: **MacOS X** Darwin's native :func:`!crypt` provides limited functionality, supporting only :class:`~passlib.hash.des_crypt` and :class:`~passlib.hash.bsdi_crypt`. OS X uses a separate - system for it's own password hashes. + system for it's own password hashes. **Google App Engine** As of 2011-08-19, Google App Engine's :func:`!crypt` implementation appears to match that of a typical Linux @@ -181,6 +181,7 @@ These hashes can be found in various libraries and applications Scheme Prefix Primary Use (if known) =========================================== =================== =========================== :class:`~passlib.hash.apr_md5_crypt` ``$apr1$`` Apache htdigest files + :class:`~passlib.hash.bcrypt_sha256` ``$bcrypt-sha256$`` Passlib-specific :class:`~passlib.hash.phpass` ``$P$``, ``$H$`` PHPass-based applications :class:`~passlib.hash.pbkdf2_sha1` ``$pbkdf2$`` Passlib-specific :class:`~passlib.hash.pbkdf2_sha256` ``$pbkdf2-sha256$`` Passlib-specific diff --git a/passlib/handlers/bcrypt.py b/passlib/handlers/bcrypt.py index e2fdd02..42f0eca 100644 --- a/passlib/handlers/bcrypt.py +++ b/passlib/handlers/bcrypt.py @@ -12,6 +12,8 @@ TODO: #============================================================================= from __future__ import with_statement, absolute_import # core +from base64 import b64encode +from hashlib import sha256 import os import re import logging; log = logging.getLogger(__name__) @@ -28,7 +30,7 @@ except ImportError: # pragma: no cover # pkg from passlib.exc import PasslibHashWarning from passlib.utils import bcrypt64, safe_crypt, repeat_string, to_bytes, \ - classproperty, rng, getrandstr, test_crypt + classproperty, rng, getrandstr, test_crypt, to_unicode from passlib.utils.compat import bytes, b, u, uascii_to_str, unicode, str_to_uascii import passlib.utils.handlers as uh @@ -271,6 +273,17 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh. def _no_backends_msg(cls): return "no bcrypt backends available - please install py-bcrypt" + def _calc_checksum(self, secret): + "common backend code" + if isinstance(secret, unicode): + secret = secret.encode("utf-8") + if _BNULL in secret: + # NOTE: especially important to forbid NULLs for bcrypt, since many + # backends (bcryptor, bcrypt) happily accept them, and then + # silently truncate the password at first NULL they encounter! + raise uh.exc.NullPasswordError(self) + return self._calc_checksum_backend(secret) + def _calc_checksum_os_crypt(self, secret): config = self._get_config() hash = safe_crypt(secret, config) @@ -294,10 +307,6 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh. # hash must be ascii bytes # secret must be bytes # returns bytes - if isinstance(secret, unicode): - secret = secret.encode("utf-8") - if _BNULL in secret: - raise uh.exc.NullPasswordError(self) if self.ident == IDENT_2: # bcrypt doesn't support $2$ hashes; but we can fake $2$ behavior # using the $2a$ algorithm, by repeating the password until @@ -320,10 +329,6 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh. # bytes taken as-is; returns ascii bytes. # py3: unicode secret encoded as utf-8 bytes, # hash encoded as ascii bytes, returns ascii unicode. - if isinstance(secret, unicode): - secret = secret.encode("utf-8") - if _BNULL in secret: - raise uh.exc.NullPasswordError(self) config = self._get_config() hash = _bcrypt.hashpw(secret, config) assert hash.startswith(config) and len(hash) == len(config)+31 @@ -334,13 +339,6 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh. # py2: unicode secret/hash encoded as ascii bytes before use, # bytes taken as-is; returns ascii bytes. # py3: not supported - if isinstance(secret, unicode): - secret = secret.encode("utf-8") - if _BNULL in secret: - # NOTE: especially important to forbid NULLs for bcryptor, - # since it happily accepts them, and then silently truncates - # the password at first one it encounters :( - raise uh.exc.NullPasswordError(self) if self.ident == IDENT_2: # bcryptor doesn't support $2$ hashes; but we can fake $2$ behavior # using the $2a$ algorithm, by repeating the password until @@ -355,10 +353,6 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh. return str_to_uascii(hash[-31:]) def _calc_checksum_builtin(self, secret): - if isinstance(secret, unicode): - secret = secret.encode("utf-8") - if _BNULL in secret: - raise uh.exc.NullPasswordError(self) chk = _builtin_bcrypt(secret, self.ident.strip("$"), self.salt.encode("ascii"), self.rounds) return chk.decode("ascii") @@ -367,6 +361,97 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh. # eoc #=================================================================== +_UDOLLAR = u("$") + +class bcrypt_sha256(bcrypt): + """This class implements a composition of BCrypt+SHA256, and follows the :ref:`password-hash-api`. + + It supports a fixed-length salt, and a variable number of rounds. + + The :meth:`~passlib.ifc.PasswordHash.encrypt` and :meth:`~passlib.ifc.PasswordHash.genconfig` methods accept + all the same optional keywords as the base :class:`bcrypt` hash. + + .. versionadded:: 1.6.2 + """ + name = "bcrypt_sha256" + + # this is locked at 2a for now. + ident_values = (IDENT_2A,) + + # sample hash: + # $bcrypt-sha256$2a,6$/3OeRpbOf8/l6nPPRdZPp.$nRiyYqPobEZGdNRBWihQhiFDh1ws1tu + # $bcrypt-sha256$ -- prefix/identifier + # 2a -- bcrypt variant + # , -- field separator + # 6 -- bcrypt work factor + # $ -- section separator + # /3OeRpbOf8/l6nPPRdZPp. -- salt + # $ -- section separator + # nRiyYqPobEZGdNRBWihQhiFDh1ws1tu -- digest + + # XXX: we can't use .ident attr due to bcrypt code using it. + # working around that via prefix. + prefix = u('$bcrypt-sha256$') + + _hash_re = re.compile(r""" + ^ + [$]bcrypt-sha256 + [$](?P<variant>[a-z0-9]+) + ,(?P<rounds>\d{1,2}) + [$](?P<salt>[^$]{22}) + ([$](?P<digest>.{31}))? + $ + """, re.X) + + @classmethod + def identify(cls, hash): + hash = uh.to_unicode_for_identify(hash) + if not hash: + return False + return hash.startswith(cls.prefix) + + @classmethod + def from_string(cls, hash): + hash = to_unicode(hash, "ascii", "hash") + if not hash.startswith(cls.prefix): + raise uh.exc.InvalidHashError(cls) + m = cls._hash_re.match(hash) + if not m: + raise uh.exc.MalformedHashError(cls) + rounds = m.group("rounds") + if rounds.startswith(uh._UZERO) and rounds != uh._UZERO: + raise uh.exc.ZeroPaddedRoundsError(cls) + return cls(ident=m.group("variant"), + rounds=int(rounds), + salt=m.group("salt"), + checksum=m.group("digest"), + ) + + def to_string(self): + hash = u("%s%s,%d$%s") % (self.prefix, self.ident.strip(_UDOLLAR), + self.rounds, self.salt) + if self.checksum: + hash = u("%s$%s") % (hash, self.checksum) + return uascii_to_str(hash) + + def _calc_checksum(self, secret): + # NOTE: this bypasses bcrypt's _calc_checksum, + # so has to take care of all it's issues, such as secret encoding. + if isinstance(secret, unicode): + secret = secret.encode("utf-8") + # NOTE: can't use digest directly, since bcrypt stops at first NULL. + # NOTE: bcrypt doesn't fully mix entropy for bytes 55-72 of password + # (XXX: citation needed), so we don't want key to be > 55 bytes. + # thus, have to use base64 (44 bytes) rather than hex (64 bytes). + key = b64encode(sha256(secret).digest()) + return self._calc_checksum_backend(key) + + # patch set_backend so it modifies bcrypt class, not this one... + # else it would clobber our _calc_checksum() wrapper above. + @classmethod + def set_backend(cls, *args, **kwds): + return bcrypt.set_backend(*args, **kwds) + #============================================================================= # eof #============================================================================= diff --git a/passlib/registry.py b/passlib/registry.py index 5a8055c..938bc5e 100644 --- a/passlib/registry.py +++ b/passlib/registry.py @@ -82,6 +82,7 @@ _locations = dict( apr_md5_crypt = "passlib.handlers.md5_crypt", atlassian_pbkdf2_sha1 = "passlib.handlers.pbkdf2", bcrypt = "passlib.handlers.bcrypt", + bcrypt_sha256 = "passlib.handlers.bcrypt", bigcrypt = "passlib.handlers.des_crypt", bsd_nthash = "passlib.handlers.windows", bsdi_crypt = "passlib.handlers.des_crypt", diff --git a/passlib/tests/test_handlers_bcrypt.py b/passlib/tests/test_handlers_bcrypt.py index 70dbf91..a6027da 100644 --- a/passlib/tests/test_handlers_bcrypt.py +++ b/passlib/tests/test_handlers_bcrypt.py @@ -27,6 +27,7 @@ class _bcrypt_test(HandlerCase): handler = hash.bcrypt secret_size = 72 reduce_default_rounds = True + fuzz_salts_need_bcrypt_repair = True known_correct_hashes = [ # @@ -360,5 +361,112 @@ bcrypt_bcrypt_test, bcrypt_pybcrypt_test, bcrypt_bcryptor_test, bcrypt_os_crypt_ _bcrypt_test.create_backend_cases(["bcrypt", "pybcrypt", "bcryptor", "os_crypt", "builtin"]) #============================================================================= +# bcrypt +#============================================================================= +class _bcrypt_sha256_test(HandlerCase): + "base for BCrypt-SHA256 test cases" + handler = hash.bcrypt_sha256 + reduce_default_rounds = True + forbidden_characters = None + fuzz_salts_need_bcrypt_repair = True + fallback_os_crypt_handler = hash.bcrypt + + known_correct_hashes = [ + # + # custom test vectors + # + + # empty + ("", + '$bcrypt-sha256$2a,5$E/e/2AOhqM5W/KJTFQzLce$F6dYSxOdAEoJZO2eoHUZWZljW/e0TXO'), + + # ascii + ("password", + '$bcrypt-sha256$2a,5$5Hg1DKFqPE8C2aflZ5vVoe$12BjNE0p7axMg55.Y/mHsYiVuFBDQyu'), + + # unicode / utf8 + (UPASS_TABLE, + '$bcrypt-sha256$2a,5$.US1fQ4TQS.ZTz/uJ5Kyn.$QNdPDOTKKT5/sovNz1iWg26quOU4Pje'), + (UPASS_TABLE.encode("utf-8"), + '$bcrypt-sha256$2a,5$.US1fQ4TQS.ZTz/uJ5Kyn.$QNdPDOTKKT5/sovNz1iWg26quOU4Pje'), + + # test >72 chars is hashed correctly -- under bcrypt these hash the same. + # NOTE: test_60_secret_size() handles this already, this is just for overkill :) + (repeat_string("abc123",72), + '$bcrypt-sha256$2a,5$X1g1nh3g0v4h6970O68cxe$r/hyEtqJ0teqPEmfTLoZ83ciAI1Q74.'), + (repeat_string("abc123",72)+"qwr", + '$bcrypt-sha256$2a,5$X1g1nh3g0v4h6970O68cxe$021KLEif6epjot5yoxk0m8I0929ohEa'), + (repeat_string("abc123",72)+"xyz", + '$bcrypt-sha256$2a,5$X1g1nh3g0v4h6970O68cxe$7.1kgpHduMGEjvM3fX6e/QCvfn6OKja'), + ] + + known_correct_configs =[ + ('$bcrypt-sha256$2a,5$5Hg1DKFqPE8C2aflZ5vVoe', + "password", '$bcrypt-sha256$2a,5$5Hg1DKFqPE8C2aflZ5vVoe$12BjNE0p7axMg55.Y/mHsYiVuFBDQyu'), + ] + + known_malformed_hashes = [ + # bad char in otherwise correct hash + # \/ + '$bcrypt-sha256$2a,5$5Hg1DKF!PE8C2aflZ5vVoe$12BjNE0p7axMg55.Y/mHsYiVuFBDQyu', + + # unrecognized bcrypt variant + '$bcrypt-sha256$2c,5$5Hg1DKFqPE8C2aflZ5vVoe$12BjNE0p7axMg55.Y/mHsYiVuFBDQyu', + + # unsupported bcrypt variant + '$bcrypt-sha256$2x,5$5Hg1DKFqPE8C2aflZ5vVoe$12BjNE0p7axMg55.Y/mHsYiVuFBDQyu', + + # rounds zero-padded + '$bcrypt-sha256$2a,05$5Hg1DKFqPE8C2aflZ5vVoe$12BjNE0p7axMg55.Y/mHsYiVuFBDQyu', + + # config string w/ $ added + '$bcrypt-sha256$2a,5$5Hg1DKFqPE8C2aflZ5vVoe$', + ] + + #=================================================================== + # override some methods -- cloned from bcrypt + #=================================================================== + def setUp(self): + # ensure builtin is enabled for duration of test. + if TEST_MODE("full") and self.backend == "builtin": + key = "PASSLIB_BUILTIN_BCRYPT" + orig = os.environ.get(key) + if orig: + self.addCleanup(os.environ.__setitem__, key, orig) + else: + self.addCleanup(os.environ.__delitem__, key) + os.environ[key] = "enabled" + super(_bcrypt_sha256_test, self).setUp() + + def populate_settings(self, kwds): + # builtin is still just way too slow. + if self.backend == "builtin": + kwds.setdefault("rounds", 4) + super(_bcrypt_sha256_test, self).populate_settings(kwds) + + #=================================================================== + # override ident tests for now + #=================================================================== + def test_30_HasManyIdents(self): + raise self.skipTest("multiple idents not supported") + + def test_30_HasOneIdent(self): + # forbidding ident keyword, we only support "2a" for now + handler = self.handler + handler(use_defaults=True) + self.assertRaises(ValueError, handler, ident="$2y$", use_defaults=True) + + #=================================================================== + # fuzz testing -- cloned from bcrypt + #=================================================================== + def fuzz_setting_rounds(self): + # decrease default rounds for fuzz testing to speed up volume. + return randintgauss(5, 8, 6, 1) + +# create test cases for specific backends +bcrypt_sha256_bcrypt_test, bcrypt_sha256_pybcrypt_test, bcrypt_sha256_bcryptor_test, bcrypt_sha256_os_crypt_test, bcrypt_sha256_builtin_test = \ + _bcrypt_sha256_test.create_backend_cases(["bcrypt", "pybcrypt", "bcryptor", "os_crypt", "builtin"]) + +#============================================================================= # eof #============================================================================= diff --git a/passlib/tests/test_handlers_django.py b/passlib/tests/test_handlers_django.py index 00a2f9b..2d516ae 100644 --- a/passlib/tests/test_handlers_django.py +++ b/passlib/tests/test_handlers_django.py @@ -270,6 +270,7 @@ class django_bcrypt_test(HandlerCase, _DjangoHelper): handler = hash.django_bcrypt secret_size = 72 min_django_version = (1,4) + fuzz_salts_need_bcrypt_repair = True known_correct_hashes = [ # @@ -306,6 +307,7 @@ class django_bcrypt_sha256_test(HandlerCase, _DjangoHelper): handler = hash.django_bcrypt_sha256 min_django_version = (1,6) forbidden_characters = None + fuzz_salts_need_bcrypt_repair = True known_correct_hashes = [ # diff --git a/passlib/tests/test_registry.py b/passlib/tests/test_registry.py index 306e95a..27c5c5c 100644 --- a/passlib/tests/test_registry.py +++ b/passlib/tests/test_registry.py @@ -198,6 +198,17 @@ class RegistryTest(TestCase): for name in list_crypt_handlers(): self.assertFalse(name.startswith("_"), "%r: " % name) + def test_handlers(self): + "verify we have tests for all handlers" + from passlib.registry import list_crypt_handlers + from passlib.tests.test_handlers import get_handler_case + for name in list_crypt_handlers(): + if name.startswith("ldap_") and name[5:] in list_crypt_handlers(): + continue + if name in ["roundup_plaintext"]: + continue + self.assertTrue(get_handler_case(name)) + #============================================================================= # eof #============================================================================= diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py index f4dc811..b840aff 100644 --- a/passlib/tests/utils.py +++ b/passlib/tests/utils.py @@ -1152,10 +1152,12 @@ class HandlerCase(TestCase): c3 = self.do_genconfig(salt=s1[:-1]) self.assertNotEqual(c3, c1) - # XXX: make this a class-level flag + # whether salt should be passed through bcrypt repair function + fuzz_salts_need_bcrypt_repair = False + def prepare_salt(self, salt): "prepare generated salt" - if self.handler.name in ["bcrypt", "django_bcrypt", "django_bcrypt_sha256"]: + if self.fuzz_salts_need_bcrypt_repair: from passlib.utils import bcrypt64 salt = bcrypt64.repair_unused(salt) return salt @@ -1958,13 +1960,17 @@ class OsCryptMixin(HandlerCase): self._patch_safe_crypt() super(OsCryptMixin, self).setUp() + # alternate handler to use for fake os_crypt, + # e.g. bcrypt_sha256 uses bcrypt + fallback_os_crypt_handler = None + def _patch_safe_crypt(self): """if crypt() doesn't support current hash alg, this patches safe_crypt() so that it transparently uses another one of the handler's backends, so that we can go ahead and test as much of code path as possible. """ - handler = self.handler + handler = self.fallback_os_crypt_handler or self.handler # resolve wrappers, since we want to return crypt compatible hash. while hasattr(handler, "wrapped"): handler = handler.wrapped diff --git a/passlib/utils/_blowfish/__init__.py b/passlib/utils/_blowfish/__init__.py index ef90ac0..16b8544 100644 --- a/passlib/utils/_blowfish/__init__.py +++ b/passlib/utils/_blowfish/__init__.py @@ -143,18 +143,20 @@ def raw_bcrypt(password, ident, salt, log_rounds): engine = BlowfishEngine() - # convert password & salt into list of 18 32-bit integers. + # convert password & salt into list of 18 32-bit integers (72 bytes total). pass_words = engine.key_to_words(password) salt_words = engine.key_to_words(salt) + # truncate salt_words to original 16 byte salt, or loop won't wrap + # correctly when passed to .eks_salted_expand() + salt_words16 = salt_words[:4] + # do EKS key schedule setup - # NOTE: [:4] is due to salt being 16 bytes originally, - # and the list needs to wrap properly - engine.eks_expand(pass_words, salt_words[:4]) + engine.eks_salted_expand(pass_words, salt_words16) # apply password & salt keys to key schedule a bunch more times. rounds = 1<<log_rounds - engine.eks_rounds_expand0(pass_words, salt_words, rounds) + engine.eks_repeated_expand(pass_words, salt_words, rounds) # encipher constant data, and encode to bytes as digest. data = list(BCRYPT_CDATA) diff --git a/passlib/utils/_blowfish/base.py b/passlib/utils/_blowfish/base.py index 65b6da0..f62aca2 100644 --- a/passlib/utils/_blowfish/base.py +++ b/passlib/utils/_blowfish/base.py @@ -378,8 +378,8 @@ class BlowfishEngine(object): #=================================================================== # eks-blowfish routines #=================================================================== - def eks_expand(self, key_words, salt_words): - "perform EKS version of Blowfish keyschedule setup" + def eks_salted_expand(self, key_words, salt_words): + "perform EKS' salted version of Blowfish keyschedule setup" # NOTE: this is the same as expand(), except for the addition # of the operations involving *salt_words*. @@ -415,7 +415,7 @@ class BlowfishEngine(object): box[i], box[i+1] = l,r = encipher(l,r) # next() i += 2 - def eks_rounds_expand0(self, key_words, salt_words, rounds): + def eks_repeated_expand(self, key_words, salt_words, rounds): "perform rounds stage of EKS keyschedule setup" expand = self.expand n = 0 diff --git a/passlib/utils/handlers.py b/passlib/utils/handlers.py index 4385f3b..4d03b3b 100644 --- a/passlib/utils/handlers.py +++ b/passlib/utils/handlers.py @@ -1443,19 +1443,23 @@ class HasManyBackends(GenericHandler): elif not cls.has_backend(name): raise exc.MissingBackendError("%s backend not available: %r" % (cls.name, name)) - cls._calc_checksum = getattr(cls, "_calc_checksum_" + name) + cls._calc_checksum_backend = getattr(cls, "_calc_checksum_" + name) cls._backend = name return name - def _calc_checksum(self, secret): - "stub for _calc_checksum(), default backend will be selected first time stub is called" + def _calc_checksum_backend(self, secret): + "stub for _calc_checksum_backend(), default backend will be selected first time stub is called" # if we got here, no backend has been loaded; so load default backend assert not self._backend, "set_backend() failed to replace lazy loader" self.set_backend() assert self._backend, "set_backend() failed to load a default backend" # this should now invoke the backend-specific version, so call it again. - return self._calc_checksum(secret) + return self._calc_checksum_backend(secret) + + def _calc_checksum(self, secret): + "wrapper for backend, for common code""" + return self._calc_checksum_backend(secret) #============================================================================= # wrappers |