Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 46 additions & 16 deletions passlib/handlers/bcrypt.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
_BNULL = b"\x00"

# reference hash of "test", used in various self-checks
TEST_HASH_2A = "$2a$04$5BJqKfqMQvV7nS.yUguNcueVirQqDBGaLXSqj.rs.pZPlNR0UX/HK"
TEST_HASH_2A = f"{IDENT_2A}04$5BJqKfqMQvV7nS.yUguNcueVirQqDBGaLXSqj.rs.pZPlNR0UX/HK"


def _detect_pybcrypt():
Expand Down Expand Up @@ -147,6 +147,7 @@ class _BcryptCommon( # type: ignore[misc]
# NOTE: these are only set on the backend mixin classes
_workrounds_initialized = False
_has_2a_wraparound_bug = False
_fails_on_long_secrets = False
_lacks_20_support = False
_lacks_2y_support = False
_lacks_2b_support = False
Expand Down Expand Up @@ -382,9 +383,11 @@ def detect_wrap_bug(ident):
# If we get here, the backend auto-truncates, test for wraparound bug
if verify(secret, bug_hash):
return True
except ValueError:
except ValueError as err:
if not mixin_cls.is_password_too_long(secret, err):
raise
# Backend explicitly will not auto-truncate, truncate the password to 72 characters
secret = secret[:72]
secret = secret[:mixin_cls.truncate_size]

# Check to make sure that the backend still hashes correctly; if not, we're in a failure case
# not related to the original wraparound bug or bcrypt >= 5.0.0 input length restriction.
Expand Down Expand Up @@ -426,18 +429,18 @@ def assert_lacks_wrap_bug(ident):
result = safe_verify("test", TEST_HASH_2A)
if result is NotImplemented:
# 2a support is required, and should always be present
raise RuntimeError(f"{backend} lacks support for $2a$ hashes")
raise RuntimeError(f"{backend} lacks support for {IDENT_2A} hashes")
if not result:
raise RuntimeError(f"{backend} incorrectly rejected $2a$ hash")
raise RuntimeError(f"{backend} incorrectly rejected {IDENT_2A} hash")
assert_lacks_8bit_bug(IDENT_2A)
if detect_wrap_bug(IDENT_2A):
if backend == "os_crypt":
# don't make this a warning for os crypt (e.g. openbsd);
# they'll have proper 2b implementation which will be used for new hashes.
# so even if we didn't have a workaround, this bug wouldn't be a concern.
logger.debug(
"%r backend has $2a$ bsd wraparound bug, enabling workaround",
backend,
"%r backend has %s bsd wraparound bug, enabling workaround",
backend, IDENT_2A
)
else:
# installed library has the bug -- want to let users know,
Expand All @@ -454,13 +457,13 @@ def assert_lacks_wrap_bug(ident):
# ----------------------------------------------------------------
# check for 2y support
# ----------------------------------------------------------------
test_hash_2y = TEST_HASH_2A.replace("2a", "2y")
test_hash_2y = TEST_HASH_2A.replace(IDENT_2A, IDENT_2Y)
result = safe_verify("test", test_hash_2y)
if result is NotImplemented:
mixin_cls._lacks_2y_support = True
logger.debug("%r backend lacks $2y$ support, enabling workaround", backend)
logger.debug("%r backend lacks %s support, enabling workaround", backend, IDENT_2Y)
elif not result:
raise RuntimeError(f"{backend} incorrectly rejected $2y$ hash")
raise RuntimeError(f"{backend} incorrectly rejected {IDENT_2Y} hash")
else:
# NOTE: Not using this as fallback candidate,
# lacks wide enough support across implementations.
Expand All @@ -474,13 +477,13 @@ def assert_lacks_wrap_bug(ident):
# ----------------------------------------------------------------
# check for 2b support
# ----------------------------------------------------------------
test_hash_2b = TEST_HASH_2A.replace("2a", "2b")
test_hash_2b = TEST_HASH_2A.replace(IDENT_2A, IDENT_2B)
result = safe_verify("test", test_hash_2b)
if result is NotImplemented:
mixin_cls._lacks_2b_support = True
logger.debug("%r backend lacks $2b$ support, enabling workaround", backend)
logger.debug("%r backend lacks %s support, enabling workaround", backend, IDENT_2B)
elif not result:
raise RuntimeError(f"{backend} incorrectly rejected $2b$ hash")
raise RuntimeError(f"{backend} incorrectly rejected {IDENT_2B} hash")
else:
mixin_cls._fallback_ident = IDENT_2B
assert_lacks_8bit_bug(IDENT_2B)
Expand Down Expand Up @@ -581,13 +584,36 @@ def _norm_digest_args(cls, secret, ident, new=False):
elif ident == IDENT_2X:
# NOTE: shouldn't get here.
# XXX: could check if backend does actually offer 'support'
raise RuntimeError("$2x$ hashes not currently supported by passlib")
raise RuntimeError(f"{IDENT_2X} hashes not currently supported by passlib")

else:
raise AssertionError(f"unexpected ident value: {ident!r}")

return secret, ident

@classmethod
def is_password_too_long(cls, secret, err):
return (cls._fails_on_long_secrets
and "password" in str(err).lower()
and len(secret) > cls.truncate_size)

@classmethod
def hash_password(cls, backend, secret, config):
try:
return backend.hashpw(secret, config)
except ValueError as err:
if not cls.is_password_too_long(secret, err):
raise
cls._check_truncate_policy(secret)
# silently truncate password to truncate_size bytes, and try again
return backend.hashpw(secret[:cls.truncate_size], config)

@classmethod
def using(cls, **kwds):
# set truncate_verify_reject if backend fails on long secrets and truncate_error is set
cls.truncate_verify_reject = cls._fails_on_long_secrets and cls.truncate_error
return super().using(**kwds)


class _NoBackend(_BcryptCommon):
"""
Expand Down Expand Up @@ -620,6 +646,8 @@ def _load_backend_mixin(mixin_cls, name, dryrun):
return False
try:
version = metadata.version("bcrypt")
# From bcrypt >= 5.0.0 is expected a failure on secrets greater than 72 characters
mixin_cls._fails_on_long_secrets = version >= "5.0.0"
except Exception:
logger.warning("(trapped) error reading bcrypt version", exc_info=True)
version = "<unknown>"
Expand Down Expand Up @@ -654,7 +682,7 @@ def _calc_checksum(self, secret):
config = self._get_config(ident)
if isinstance(config, str):
config = config.encode("ascii")
hash = _bcrypt.hashpw(secret, config)
hash = self.hash_password(_bcrypt, secret, config)
assert isinstance(hash, bytes)
if not hash.startswith(config) or len(hash) != len(config) + 31:
raise uh.exc.CryptBackendError(
Expand Down Expand Up @@ -696,7 +724,9 @@ class bcrypt(_NoBackend, _BcryptCommon): # type: ignore[misc]
* ``"2b"`` - latest revision of the official BCrypt algorithm, current default.

:param bool truncate_error:
By default, BCrypt will silently truncate passwords larger than 72 bytes.
By default, BCrypt will silently truncate passwords larger than 72 bytes (in bcrypt < 5.0.0)
or raise a ValueError (in bcrypt >= 5.0.0).
Setting ``truncate_error=False`` will maintain backward compatibility by truncating long passwords silently.
Setting ``truncate_error=True`` will cause :meth:`~passlib.ifc.PasswordHash.hash`
to raise a :exc:`~passlib.exc.PasswordTruncateError` instead.

Expand Down
2 changes: 1 addition & 1 deletion tests/test_handlers_bcrypt.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ def check_bcrypt(secret, hash):
hash = IDENT_2B + hash[4:]
hash = to_bytes(hash)
try:
return bcrypt.hashpw(secret, hash) == hash
return self.handler.hash_password(bcrypt, secret, hash) == hash
except ValueError:
raise ValueError(f"bcrypt rejected hash: {hash!r} (secret={secret!r})")

Expand Down
11 changes: 6 additions & 5 deletions tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2075,7 +2075,7 @@ def test_secret_w_truncate_size(self):
# setup vars
# --------------------------------------------------
# try to get versions w/ and w/o truncate_error set.
# set to None if policy isn't configruable
# set to None if policy isn't configurable
size_error_type = exc.PasswordSizeError
if "truncate_error" in handler.setting_kwds:
without_error = handler.using(truncate_error=False)
Expand Down Expand Up @@ -2126,10 +2126,11 @@ def test_secret_w_truncate_size(self):

# verify should truncate long secret before comparing
# (unless truncate_verify_reject is set)
assert (
self.do_verify(long_secret, short_hash, handler=cand_hasher)
== long_verify_success
)
if not (cand_hasher and cand_hasher.truncate_verify_reject):
assert (
self.do_verify(long_secret, short_hash, handler=cand_hasher)
== long_verify_success
)

# --------------------------------------------------
# do tests on <truncate_size+1> length secret,
Expand Down