diff options
author | Eli Collins <elic@assurancetechnologies.com> | 2012-03-12 22:44:22 -0400 |
---|---|---|
committer | Eli Collins <elic@assurancetechnologies.com> | 2012-03-12 22:44:22 -0400 |
commit | e89ebdf93b92dc018bd3ee1542cc4416b5024ab4 (patch) | |
tree | 49afbf5441e910c2667dd0cb1e8a075467ad857d /passlib | |
parent | ca830cd76a655f20488aebd082aba1a320e230d0 (diff) | |
download | passlib-e89ebdf93b92dc018bd3ee1542cc4416b5024ab4.tar.gz |
bcrypt work
* added code to shoehorn $2$-support wrapper for bcryptor backend
* added PasslibSecurityWarning when builtin backend is enabled
(still considered whether it should be enabled by default)
* py3 compat fix for repair_unused
Diffstat (limited to 'passlib')
-rw-r--r-- | passlib/exc.py | 8 | ||||
-rw-r--r-- | passlib/handlers/bcrypt.py | 89 | ||||
-rw-r--r-- | passlib/tests/test_handlers.py | 42 | ||||
-rw-r--r-- | passlib/utils/__init__.py | 5 |
4 files changed, 101 insertions, 43 deletions
diff --git a/passlib/exc.py b/passlib/exc.py index 0192951..cb158e7 100644 --- a/passlib/exc.py +++ b/passlib/exc.py @@ -56,6 +56,14 @@ class PasslibRuntimeWarning(PasslibWarning): that the developers would love to hear under what conditions it occurred. """ +class PasslibSecurityWarning(PasslibWarning): + """Special warning issued when Passlib encounters something + that might affect security. + + The main reason this is issued is when Passlib's pure-python bcrypt + backend is used, to warn that it's 20x too slow to acheive real security. + """ + #========================================================================== # eof #========================================================================== diff --git a/passlib/handlers/bcrypt.py b/passlib/handlers/bcrypt.py index efe42f4..a1270e2 100644 --- a/passlib/handlers/bcrypt.py +++ b/passlib/handlers/bcrypt.py @@ -4,7 +4,7 @@ Implementation of OpenBSD's BCrypt algorithm. TODO: -* support 2x and altered-2a hashes? +* support 2x and altered-2a hashes? http://www.openwall.com/lists/oss-security/2011/06/27/9 * is there any workaround for bcryptor lacking $2$ support? @@ -23,17 +23,17 @@ from warnings import warn #site try: from bcrypt import hashpw as pybcrypt_hashpw -except ImportError: #pragma: no cover - though should run whole suite w/o pybcrypt installed +except ImportError: #pragma: no cover pybcrypt_hashpw = None try: from bcryptor.engine import Engine as bcryptor_engine -except ImportError: #pragma: no cover - though should run whole suite w/o bcryptor installed +except ImportError: #pragma: no cover bcryptor_engine = None #libs -from passlib.exc import PasslibHashWarning +from passlib.exc import PasslibHashWarning, PasslibSecurityWarning from passlib.utils import bcrypt64, safe_crypt, \ classproperty, rng, getrandstr, test_crypt -from passlib.utils.compat import bytes, u, uascii_to_str, unicode +from passlib.utils.compat import bytes, u, uascii_to_str, unicode, str_to_uascii import passlib.utils.handlers as uh #pkg @@ -49,6 +49,7 @@ def _load_builtin(): if _builtin_bcrypt is None: from passlib.utils._blowfish import raw_bcrypt as _builtin_bcrypt +IDENT_2 = u("$2$") IDENT_2A = u("$2a$") IDENT_2X = u("$2x$") IDENT_2Y = u("$2y$") @@ -111,7 +112,7 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh. #NOTE: 22nd salt char must be in bcrypt64._padinfo2[1], not full charmap #--HasRounds-- - default_rounds = 12 #current passlib default + default_rounds = 12 # current passlib default min_rounds = 4 # bcrypt spec specified minimum max_rounds = 31 # 32-bit integer limit (since real_rounds=1<<rounds) rounds_cost = "log2" @@ -123,6 +124,9 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh. @classmethod def from_string(cls, hash): ident, tail = cls._parse_ident(hash) + if ident == IDENT_2X: + raise ValueError("crypt_blowfish's buggy '2x' hashes are not " + "currently supported") rounds, data = tail.split(u("$")) rval = int(rounds) if rounds != u('%02d') % (rval,): @@ -135,19 +139,22 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh. ident=ident, ) - def to_string(self, _for_backend=False): - ident = self.ident - if _for_backend and ident == IDENT_2Y: - # hack so we can pass 2y strings to pybcrypt etc, - # which only honors 2/2a. - ident = IDENT_2A - elif ident == IDENT_2X: - raise ValueError("crypt_blowfish's buggy '2x' hashes are not " - "currently supported") - hash = u("%s%02d$%s%s") % (ident, self.rounds, self.salt, + def to_string(self): + hash = u("%s%02d$%s%s") % (self.ident, self.rounds, self.salt, self.checksum or u('')) return uascii_to_str(hash) + def _get_config(self, ident=None): + "internal helper to prepare config string for backends" + if ident is None: + ident = self.ident + if ident == IDENT_2Y: + ident = IDENT_2A + else: + assert ident != IDENT_2X + config = u("%s%02d$%s") % (ident, self.rounds, self.salt) + return uascii_to_str(config) + #========================================================= # specialized salt generation - fixes passlib issue 25 #========================================================= @@ -219,61 +226,71 @@ class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh. @classproperty def _has_backend_builtin(cls): - if os.environ.get("PASSLIB_BUILTIN_BCRYPT") != "enabled": + if os.environ.get("PASSLIB_BUILTIN_BCRYPT") not in ["enable","enabled"]: return False - #look at it cross-eyed, and it loads itself + # look at it cross-eyed, and it loads itself _load_builtin() return True @classproperty def _has_backend_os_crypt(cls): - # XXX: what to do if only h2 is supported? h1 is very rare. + # XXX: what to do if only h2 is supported? h1 is *very* rare. h1 = '$2$04$......................1O4gOrCYaqBG3o/4LnT2ykQUt1wbyju' h2 = '$2a$04$......................qiOQjkB8hxU8OzRhS.GhRMa4VUnkPty' return test_crypt("test",h1) and test_crypt("test", h2) @classmethod def _no_backends_msg(cls): - return "no BCrypt backends available - please install pybcrypt or bcryptor for BCrypt support" + return "no bcrypt backends available - please install 'py-bcrypt' or " \ + "'bcryptor' for bcrypt support" def _calc_checksum_os_crypt(self, secret): - hash = safe_crypt(secret, self.to_string(_for_backend=True)) + hash = safe_crypt(secret, self._get_config()) if hash: return hash[-31:] else: #NOTE: not checking backends since this is lowest priority, # so they probably aren't available either - raise ValueError("encoded password can't be handled by os_crypt" - " (recommend installing pybcrypt or bcryptor)") + raise ValueError("encoded password can't be handled by os_crypt, " + "recommend installing py-bcrypt or bcryptor.") def _calc_checksum_pybcrypt(self, secret): - #pybcrypt behavior: + #py-bcrypt behavior: # py2: unicode secret/hash encoded as ascii bytes before use, - # bytes takes as-is; returns ascii bytes. - # py3: not supported + # bytes taken as-is; returns ascii bytes. + # py3: not supported (patch submitted) if isinstance(secret, unicode): secret = secret.encode("utf-8") - hash = pybcrypt_hashpw(secret, self.to_string(_for_backend=True)) - return hash[-31:].decode("ascii") + hash = pybcrypt_hashpw(secret, self._get_config()) + return str_to_uascii(hash[-31:]) def _calc_checksum_bcryptor(self, secret): #bcryptor behavior: # py2: unicode secret/hash encoded as ascii bytes before use, - # bytes takes as-is; returns ascii bytes. + # bytes taken as-is; returns ascii bytes. # py3: not supported - - # FIXME: bcryptor doesn't support v0 hashes ("$2$"), - # will throw bcryptor.engine.SaltError at this point. - if isinstance(secret, unicode): secret = secret.encode("utf-8") - hash = bcryptor_engine(False).hash_key(secret, - self.to_string(_for_backend=True)) - return hash[-31:].decode("ascii") + 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 + # it's at least 72 chars in length. + ss = len(secret) + if 0 < ss < 72: + secret = secret * (1 + 72//ss) + config = self._get_config(IDENT_2A) + else: + config = self._get_config() + hash = bcryptor_engine(False).hash_key(secret, config) + return str_to_uascii(hash[-31:]) def _calc_checksum_builtin(self, secret): if secret is None: raise TypeError("no secret provided") + warn("SECURITY WARNING: Passlib is using it's pure-python bcrypt " + "implementation, which is TOO SLOW FOR PRODUCTION USE. It is " + "strongly recommended that you install py-bcrypt or bcryptor for " + "Passlib to use instead.", PasslibSecurityWarning) if isinstance(secret, unicode): secret = secret.encode("utf-8") chk = _builtin_bcrypt(secret, self.ident.strip("$"), diff --git a/passlib/tests/test_handlers.py b/passlib/tests/test_handlers.py index 4547e56..29c77ef 100644 --- a/passlib/tests/test_handlers.py +++ b/passlib/tests/test_handlers.py @@ -119,6 +119,25 @@ class _bcrypt_test(HandlerCase): '$2a$05$Z17AXnnlpzddNUvnC6cZNOSwMA/8oNiKnHTHTwLlBijfucQQlHjaG'), ] + if enable_option("cover"): + # + # add some extra tests related to 2/2a + # + CONFIG_2 = '$2$05$' + '.'*22 + CONFIG_A = '$2a$05$' + '.'*22 + known_correct_hashes.extend([ + ("", CONFIG_2 + 'J2ihDv8vVf7QZ9BsaRrKyqs2tkn55Yq'), + ("", CONFIG_A + 'J2ihDv8vVf7QZ9BsaRrKyqs2tkn55Yq'), + ("abc", CONFIG_2 + 'XuQjdH.wPVNUZ/bOfstdW/FqB8QSjte'), + ("abc", CONFIG_A + 'ev6gDwpVye3oMCUpLY85aTpfBNHD0Ga'), + ("abc"*23, CONFIG_2 + 'XuQjdH.wPVNUZ/bOfstdW/FqB8QSjte'), + ("abc"*23, CONFIG_A + '2kIdfSj/4/R/Q6n847VTvc68BXiRYZC'), + ("abc"*24, CONFIG_2 + 'XuQjdH.wPVNUZ/bOfstdW/FqB8QSjte'), + ("abc"*24, CONFIG_A + 'XuQjdH.wPVNUZ/bOfstdW/FqB8QSjte'), + ("abc"*24+'x', CONFIG_2 + 'XuQjdH.wPVNUZ/bOfstdW/FqB8QSjte'), + ("abc"*24+'x', CONFIG_A + 'XuQjdH.wPVNUZ/bOfstdW/FqB8QSjte'), + ]) + known_correct_configs = [ ('$2a$10$Z17AXnnlpzddNUvnC6cZNO', UPASS_TABLE, '$2a$10$Z17AXnnlpzddNUvnC6cZNOl54vBeVTewdrxohbPtcwl.GEZFTGjHe'), @@ -138,7 +157,7 @@ class _bcrypt_test(HandlerCase): # unsupported (but recognized) minor version "$2x$12$EXRkfkdmXnagzds2SSitu.MW9.gAVqa9eLS1//RYtYCmB1eLHg.9q", - # rounds not zero-padded (pybcrypt rejects this, therefore so do we) + # rounds not zero-padded (py-bcrypt rejects this, therefore so do we) '$2a$6$DCq7YPn5Rq63x1Lad4cll.TV4S6ytwfsfvkgY8jIucDrjc8deX1s.' #NOTE: salts with padding bits set are technically malformed, @@ -148,6 +167,12 @@ class _bcrypt_test(HandlerCase): #=============================================================== # override some methods #=============================================================== + def setUp(self): + HandlerCase.setUp(self) + if self.backend == "builtin": + warnings.filterwarnings("ignore", + "SECURITY WARNING: .*pure-python bcrypt.*") + def do_genconfig(self, **kwds): # override default to speed up tests kwds.setdefault("rounds", 5) @@ -169,7 +194,7 @@ class _bcrypt_test(HandlerCase): def get_fuzz_verifiers(self): verifiers = super(_bcrypt_test, self).get_fuzz_verifiers() - # test other backends against pybcrypt if available + # test other backends against py-bcrypt if available from passlib.utils import to_native_str try: from bcrypt import hashpw @@ -184,7 +209,7 @@ class _bcrypt_test(HandlerCase): try: return hashpw(secret, hash) == hash except ValueError: - raise ValueError("pybcrypt rejected hash: %r" % (hash,)) + raise ValueError("py-bcrypt rejected hash: %r" % (hash,)) verifiers.append(check_pybcrypt) # test other backends against bcryptor if available @@ -198,6 +223,14 @@ class _bcrypt_test(HandlerCase): secret = to_native_str(secret, self.fuzz_password_encoding) if hash.startswith("$2y$"): hash = "$2a$" + hash[4:] + elif hash.startswith("$2$"): + # bcryptor doesn't support $2$ hashes; but we can fake it + # using the $2a$ algorithm, by repeating the password until + # it's 72 chars in length. + hash = "$2a$" + hash[3:] + ss = len(secret) + if 0 < ss < 72: + secret = secret * (1+72//ss) return Engine(False).hash_key(secret, hash) == hash verifiers.append(check_bcryptor) @@ -212,9 +245,6 @@ class _bcrypt_test(HandlerCase): if ident == u("$2x$"): # just recognized, not currently supported. return None - if ident == u("$2$") and self.handler.has_backend("bcryptor"): - # FIXME: skipping this since bcryptor doesn't support v0 hashes - return None return ident #=============================================================== diff --git a/passlib/utils/__init__.py b/passlib/utils/__init__.py index 6d26d91..24173f7 100644 --- a/passlib/utils/__init__.py +++ b/passlib/utils/__init__.py @@ -974,11 +974,14 @@ class Base64Engine(object): if isinstance(source, unicode): cm = self.charmap last = cm[cm.index(last) & mask] + assert last in padset, "failed to generate valid padding char" else: # NOTE: this assumes ascii-compat encoding, and that # all chars used by encoding are 7-bit ascii. last = self._encode64(self._decode64(last) & mask) - assert last in padset, "failed to generate valid padding char" + assert last in padset, "failed to generate valid padding char" + if PY3: + last = bytes([last]) return True, source[:-1] + last def repair_unused(self, source): |