458 lines
18 KiB
Python

"""passlib.bcrypt -- implementation of OpenBSD's BCrypt algorithm.
TODO:
* support 2x and altered-2a hashes?
http://www.openwall.com/lists/oss-security/2011/06/27/9
* deal with lack of PY3-compatibile c-ext implementation
"""
#=============================================================================
# imports
#=============================================================================
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__)
from warnings import warn
# site
try:
import bcrypt as _bcrypt
except ImportError: # pragma: no cover
_bcrypt = None
try:
from bcryptor.engine import Engine as bcryptor_engine
except ImportError: # pragma: no cover
bcryptor_engine = None
# pkg
from passlib.exc import PasslibHashWarning
from passlib.utils import bcrypt64, safe_crypt, repeat_string, to_bytes, \
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
# local
__all__ = [
"bcrypt",
]
#=============================================================================
# support funcs & constants
#=============================================================================
_builtin_bcrypt = None
def _load_builtin():
global _builtin_bcrypt
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$")
_BNULL = b('\x00')
#=============================================================================
# handler
#=============================================================================
class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh.GenericHandler):
"""This class implements the BCrypt password hash, 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 the following optional keywords:
:type salt: str
:param salt:
Optional salt string.
If not specified, one will be autogenerated (this is recommended).
If specified, it must be 22 characters, drawn from the regexp range ``[./0-9A-Za-z]``.
:type rounds: int
:param rounds:
Optional number of rounds to use.
Defaults to 12, must be between 4 and 31, inclusive.
This value is logarithmic, the actual number of iterations used will be :samp:`2**{rounds}`
-- increasing the rounds by +1 will double the amount of time taken.
:type ident: str
:param ident:
Specifies which version of the BCrypt algorithm will be used when creating a new hash.
Typically this option is not needed, as the default (``"2a"``) is usually the correct choice.
If specified, it must be one of the following:
* ``"2"`` - the first revision of BCrypt, which suffers from a minor security flaw and is generally not used anymore.
* ``"2a"`` - latest revision of the official BCrypt algorithm, and the current default.
* ``"2y"`` - format specific to the *crypt_blowfish* BCrypt implementation,
identical to ``"2a"`` in all but name.
:type relaxed: bool
:param relaxed:
By default, providing an invalid value for one of the other
keywords will result in a :exc:`ValueError`. If ``relaxed=True``,
and the error can be corrected, a :exc:`~passlib.exc.PasslibHashWarning`
will be issued instead. Correctable errors include ``rounds``
that are too small or too large, and ``salt`` strings that are too long.
.. versionadded:: 1.6
.. versionchanged:: 1.6
This class now supports ``"2y"`` hashes, and recognizes
(but does not support) the broken ``"2x"`` hashes.
(see the :ref:`crypt_blowfish bug <crypt-blowfish-bug>`
for details).
.. versionchanged:: 1.6
Added a pure-python backend.
"""
#===================================================================
# class attrs
#===================================================================
#--GenericHandler--
name = "bcrypt"
setting_kwds = ("salt", "rounds", "ident")
checksum_size = 31
checksum_chars = bcrypt64.charmap
#--HasManyIdents--
default_ident = IDENT_2A
ident_values = (IDENT_2, IDENT_2A, IDENT_2X, IDENT_2Y)
ident_aliases = {u("2"): IDENT_2, u("2a"): IDENT_2A, u("2y"): IDENT_2Y}
#--HasSalt--
min_salt_size = max_salt_size = 22
salt_chars = bcrypt64.charmap
# NOTE: 22nd salt char must be in bcrypt64._padinfo2[1], not full charmap
#--HasRounds--
default_rounds = 12 # current passlib default
min_rounds = 4 # minimum from bcrypt specification
max_rounds = 31 # 32-bit integer limit (since real_rounds=1<<rounds)
rounds_cost = "log2"
#===================================================================
# formatting
#===================================================================
@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_str, data = tail.split(u("$"))
rounds = int(rounds_str)
if rounds_str != u('%02d') % (rounds,):
raise uh.exc.MalformedHashError(cls, "malformed cost field")
salt, chk = data[:22], data[22:]
return cls(
rounds=rounds,
salt=salt,
checksum=chk or None,
ident=ident,
)
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:
# none of passlib's backends suffered from crypt_blowfish's
# buggy "2a" hash, which means we can safely implement
# crypt_blowfish's "2y" hash by passing "2a" to the backends.
ident = IDENT_2A
else:
# no backends currently support 2x, but that should have
# been caught earlier in from_string()
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
#===================================================================
@classmethod
def _bind_needs_update(cls, **settings):
return cls._needs_update
@classmethod
def _needs_update(cls, hash, secret):
if isinstance(hash, bytes):
hash = hash.decode("ascii")
# check for incorrect padding bits (passlib issue 25)
if hash.startswith(IDENT_2A) and hash[28] not in bcrypt64._padinfo2[1]:
return True
# TODO: try to detect incorrect $2x$ hashes using *secret*
return False
@classmethod
def normhash(cls, hash):
"helper to normalize hash, correcting any bcrypt padding bits"
if cls.identify(hash):
return cls.from_string(hash).to_string()
else:
return hash
def _generate_salt(self, salt_size):
# generate random salt as normal,
# but repair last char so the padding bits always decode to zero.
salt = super(bcrypt, self)._generate_salt(salt_size)
return bcrypt64.repair_unused(salt)
def _norm_salt(self, salt, **kwds):
salt = super(bcrypt, self)._norm_salt(salt, **kwds)
assert salt is not None, "HasSalt didn't generate new salt!"
changed, salt = bcrypt64.check_repair_unused(salt)
if changed:
# FIXME: if salt was provided by user, this message won't be
# correct. not sure if we want to throw error, or use different warning.
warn(
"encountered a bcrypt salt with incorrectly set padding bits; "
"you may want to use bcrypt.normhash() "
"to fix this; see Passlib 1.5.3 changelog.",
PasslibHashWarning)
return salt
def _norm_checksum(self, checksum):
checksum = super(bcrypt, self)._norm_checksum(checksum)
if not checksum:
return None
changed, checksum = bcrypt64.check_repair_unused(checksum)
if changed:
warn(
"encountered a bcrypt hash with incorrectly set padding bits; "
"you may want to use bcrypt.normhash() "
"to fix this; see Passlib 1.5.3 changelog.",
PasslibHashWarning)
return checksum
#===================================================================
# primary interface
#===================================================================
backends = ("bcrypt", "pybcrypt", "bcryptor", "os_crypt", "builtin")
@classproperty
def _has_backend_bcrypt(cls):
return _bcrypt is not None and hasattr(_bcrypt, "_ffi")
@classproperty
def _has_backend_pybcrypt(cls):
return _bcrypt is not None and not hasattr(_bcrypt, "_ffi")
@classproperty
def _has_backend_bcryptor(cls):
return bcryptor_engine is not None
@classproperty
def _has_backend_builtin(cls):
if os.environ.get("PASSLIB_BUILTIN_BCRYPT") not in ["enable","enabled"]:
return False
# 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 "2" isn't supported, but "2a" is?
# "2" is *very* rare, and can fake it using "2a"+repeat_string
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 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)
if hash:
assert hash.startswith(config) and len(hash) == len(config)+31
return hash[-31:]
else:
# NOTE: it's unlikely any other backend will be available,
# but checking before we bail, just in case.
for name in self.backends:
if name != "os_crypt" and self.has_backend(name):
func = getattr(self, "_calc_checksum_" + name)
return func(secret)
raise uh.exc.MissingBackendError(
"password can't be handled by os_crypt, "
"recommend installing py-bcrypt.",
)
def _calc_checksum_bcrypt(self, secret):
# bcrypt behavior:
# hash must be ascii bytes
# secret must be bytes
# returns bytes
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
# it's at least 72 chars in length.
if secret:
secret = repeat_string(secret, 72)
config = self._get_config(IDENT_2A)
else:
config = self._get_config()
if isinstance(config, unicode):
config = config.encode("ascii")
hash = _bcrypt.hashpw(secret, config)
assert hash.startswith(config) and len(hash) == len(config)+31
assert isinstance(hash, bytes)
return hash[-31:].decode("ascii")
def _calc_checksum_pybcrypt(self, secret):
# py-bcrypt behavior:
# py2: unicode secret/hash encoded as ascii bytes before use,
# bytes taken as-is; returns ascii bytes.
# py3: unicode secret encoded as utf-8 bytes,
# hash encoded as ascii bytes, returns ascii unicode.
config = self._get_config()
hash = _bcrypt.hashpw(secret, config)
assert hash.startswith(config) and len(hash) == len(config)+31
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 taken as-is; returns ascii bytes.
# py3: not supported
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.
if secret:
secret = repeat_string(secret, 72)
config = self._get_config(IDENT_2A)
else:
config = self._get_config()
hash = bcryptor_engine(False).hash_key(secret, config)
assert hash.startswith(config) and len(hash) == len(config)+31
return str_to_uascii(hash[-31:])
def _calc_checksum_builtin(self, secret):
chk = _builtin_bcrypt(secret, self.ident.strip("$"),
self.salt.encode("ascii"), self.rounds)
return chk.decode("ascii")
#===================================================================
# 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
#=============================================================================