From 8c9a7df3dc21ddae1d40ab00993414eb7e511983 Mon Sep 17 00:00:00 2001 From: mo7ty Date: Mon, 2 Jun 2025 21:41:11 +0100 Subject: [PATCH 01/12] Tweak `passlib.handlers.bcrypt._BcryptCommon._finalize_backend_mixin` * Use declared `IDENT_*`'s in corresponding checks --- passlib/handlers/bcrypt.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/passlib/handlers/bcrypt.py b/passlib/handlers/bcrypt.py index 121a12c..2207bff 100644 --- a/passlib/handlers/bcrypt.py +++ b/passlib/handlers/bcrypt.py @@ -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(): @@ -426,9 +426,9 @@ 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": @@ -436,8 +436,8 @@ def assert_lacks_wrap_bug(ident): # 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, @@ -454,13 +454,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. @@ -474,13 +474,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) @@ -581,7 +581,7 @@ 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}") From 3be3773e2294299063beadb3cb9d13d20d050554 Mon Sep 17 00:00:00 2001 From: mo7ty Date: Sat, 11 Oct 2025 15:32:42 +0100 Subject: [PATCH 02/12] Detect BCrypt >= 5.0.0 expected `ValueError` for long secrets * Handle possible `ValueError` during `_finalize_backend_mixin` if expected * Add and use `passlib.handlers.bcrypt._BcryptCommon.`[+is_password_too_long()+] helper method * Add and use `passlib.handlers.bcrypt._BcryptCommon.`[+hash_password()+] helper method * Override `passlib.utils.handlers.TruncateMixin.using()` for `_BcryptCommon`: set `truncate_verify_reject` if `_fails_on_long_secrets and truncate_error` * Refer to [BCrypt wrap bug detected #18](https://github.com/notypecheck/passlib/issues/18) --- passlib/handlers/bcrypt.py | 39 ++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/passlib/handlers/bcrypt.py b/passlib/handlers/bcrypt.py index 2207bff..a0fdf38 100644 --- a/passlib/handlers/bcrypt.py +++ b/passlib/handlers/bcrypt.py @@ -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 @@ -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. @@ -588,6 +591,30 @@ def _norm_digest_args(cls, secret, ident, new=False): 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 + if cls.truncate_error: + raise uh.exc.PasswordTruncateError(cls) from err + # 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): """ @@ -620,6 +647,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 = "" @@ -654,7 +683,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( @@ -696,7 +725,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. From da751b91b5ed3c11eeaea2a77ef25735bc105cbe Mon Sep 17 00:00:00 2001 From: mo7ty Date: Sat, 11 Oct 2025 18:55:14 +0100 Subject: [PATCH 03/12] Tweak comment on `tests.utils.HandlerCase.test_secret_w_truncate_size()` --- tests/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils.py b/tests/utils.py index b08847d..31a77fc 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -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) From 5ed5618a70f6eff9a88895faf201d06c826857ee Mon Sep 17 00:00:00 2001 From: mo7ty Date: Sun, 12 Oct 2025 15:17:14 +0100 Subject: [PATCH 04/12] Update `test_handlers_bcrypt/test_77_fuzz_input()` for BCrypt >= 5.0.0 * Update `_bcrypt_test.fuzz_verifier_bcrypt()/check_bcrypt()` to use `passlib.handlers.bcrypt._BcryptCommon.hash_password()` --- tests/test_handlers_bcrypt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_handlers_bcrypt.py b/tests/test_handlers_bcrypt.py index b49c1fb..bb99104 100644 --- a/tests/test_handlers_bcrypt.py +++ b/tests/test_handlers_bcrypt.py @@ -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})") From 3826f12adeb323006344869d23cc7b5f14e9be20 Mon Sep 17 00:00:00 2001 From: mo7ty Date: Sun, 12 Oct 2025 15:26:19 +0100 Subject: [PATCH 05/12] Update `tests.utils.HandlerCase.test_secret_w_truncate_size()` * Consider `cand_hasher.truncate_verify_reject` to verify if should truncate long secret before comparing --- tests/utils.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/utils.py b/tests/utils.py index 31a77fc..e6d4573 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -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 length secret, From 9de2a8913cd2df5c588a93bcebffc9b213ac42b6 Mon Sep 17 00:00:00 2001 From: mo7ty Date: Sun, 12 Oct 2025 17:32:59 +0100 Subject: [PATCH 06/12] Update `passlib.handlers.bcrypt._BcryptCommon.hash_password()` to use `TruncateMixin._check_truncate_policy()` --- passlib/handlers/bcrypt.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/passlib/handlers/bcrypt.py b/passlib/handlers/bcrypt.py index a0fdf38..ffc5f79 100644 --- a/passlib/handlers/bcrypt.py +++ b/passlib/handlers/bcrypt.py @@ -604,8 +604,7 @@ def hash_password(cls, backend, secret, config): except ValueError as err: if not cls.is_password_too_long(secret, err): raise - if cls.truncate_error: - raise uh.exc.PasswordTruncateError(cls) from err + cls._check_truncate_policy(secret) # silently truncate password to truncate_size bytes, and try again return backend.hashpw(secret[:cls.truncate_size], config) From 0754ab28258f8eb36e6927586ac6110da14723b0 Mon Sep 17 00:00:00 2001 From: mo7ty Date: Tue, 14 Oct 2025 09:23:02 +0100 Subject: [PATCH 07/12] Detect bcrypt >= 5.0.0 using `packaging.version` to set `_fails_on_long_secrets flag` --- passlib/handlers/bcrypt.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/passlib/handlers/bcrypt.py b/passlib/handlers/bcrypt.py index ffc5f79..2b81961 100644 --- a/passlib/handlers/bcrypt.py +++ b/passlib/handlers/bcrypt.py @@ -17,6 +17,8 @@ from importlib.util import find_spec from warnings import warn +from packaging.version import parse + import passlib.utils.handlers as uh from passlib._logging import logger from passlib.crypto.digest import compile_hmac @@ -647,7 +649,7 @@ def _load_backend_mixin(mixin_cls, name, dryrun): 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" + mixin_cls._fails_on_long_secrets = parse(version) >= parse("5.0.0") except Exception: logger.warning("(trapped) error reading bcrypt version", exc_info=True) version = "" From 4cf5d8af50f8ce70ba0ac8086f726f733059c2ec Mon Sep 17 00:00:00 2001 From: mo7ty Date: Tue, 14 Oct 2025 13:10:25 +0100 Subject: [PATCH 08/12] Revert "Update `test_handlers_bcrypt/test_77_fuzz_input()` for BCrypt >= 5.0.0" This reverts commit 5ed5618a70f6eff9a88895faf201d06c826857ee. --- tests/test_handlers_bcrypt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_handlers_bcrypt.py b/tests/test_handlers_bcrypt.py index bb99104..b49c1fb 100644 --- a/tests/test_handlers_bcrypt.py +++ b/tests/test_handlers_bcrypt.py @@ -220,7 +220,7 @@ def check_bcrypt(secret, hash): hash = IDENT_2B + hash[4:] hash = to_bytes(hash) try: - return self.handler.hash_password(bcrypt, secret, hash) == hash + return bcrypt.hashpw(secret, hash) == hash except ValueError: raise ValueError(f"bcrypt rejected hash: {hash!r} (secret={secret!r})") From e9a67891bc68e4a2fb0508cc27a3140c553baa4f Mon Sep 17 00:00:00 2001 From: mo7ty Date: Tue, 14 Oct 2025 13:10:36 +0100 Subject: [PATCH 09/12] Revert "Update `tests.utils.HandlerCase.test_secret_w_truncate_size()`" This reverts commit 3826f12adeb323006344869d23cc7b5f14e9be20. --- tests/utils.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/utils.py b/tests/utils.py index e6d4573..31a77fc 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -2126,11 +2126,10 @@ def test_secret_w_truncate_size(self): # verify should truncate long secret before comparing # (unless truncate_verify_reject is set) - if not (cand_hasher and cand_hasher.truncate_verify_reject): - assert ( - self.do_verify(long_secret, short_hash, handler=cand_hasher) - == long_verify_success - ) + assert ( + self.do_verify(long_secret, short_hash, handler=cand_hasher) + == long_verify_success + ) # -------------------------------------------------- # do tests on length secret, From 83c6a1e899b5911f74be2b57bf393b3d4a9d098e Mon Sep 17 00:00:00 2001 From: mo7ty Date: Tue, 14 Oct 2025 15:59:07 +0100 Subject: [PATCH 10/12] Update handle for BCrypt >= 5.0.0 expected `ValueError` for long secrets * Rename and improve `passlib.handlers.bcrypt._BcryptCommon.is_secret_truncate_err` helper method * Add and use `passlib.handlers.bcrypt._BcryptBackend.`[+_check_truncate_flag()+] helper method for `TruncateMixin` flags validation * Add and use `passlib.handlers.bcrypt._BcryptBackend.`[+_handle_w_truncate()+] to handle retries for truncate errors * Implement `passlib.handlers.bcrypt._BcryptBackend.`[+verify+] * Update `passlib.handlers.bcrypt._BcryptBackend._calc_checksum` to use `_handle_w_truncate(_bcrypt.hashpw, truncate_error)` * Revert "Detect BCrypt >= 5.0.0 expected `ValueError` for long secrets" - commit 3be3773e2294299063beadb3cb9d13d20d050554 * Refer to [BCrypt wrap bug detected #18](https://github.com/notypecheck/passlib/issues/18) --- passlib/handlers/bcrypt.py | 56 ++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/passlib/handlers/bcrypt.py b/passlib/handlers/bcrypt.py index 2b81961..89ec20a 100644 --- a/passlib/handlers/bcrypt.py +++ b/passlib/handlers/bcrypt.py @@ -20,9 +20,10 @@ from packaging.version import parse import passlib.utils.handlers as uh +from passlib import exc from passlib._logging import logger from passlib.crypto.digest import compile_hmac -from passlib.exc import PasslibHashWarning, PasslibSecurityError +from passlib.exc import PasslibHashWarning, PasslibSecurityError, PasswordSizeError, PasswordTruncateError from passlib.utils import ( repeat_string, to_unicode, @@ -386,7 +387,7 @@ def detect_wrap_bug(ident): if verify(secret, bug_hash): return True except ValueError as err: - if not mixin_cls.is_password_too_long(secret, err): + if not mixin_cls.is_secret_truncate_err(secret, err): raise # Backend explicitly will not auto-truncate, truncate the password to 72 characters secret = secret[:mixin_cls.truncate_size] @@ -594,28 +595,15 @@ def _norm_digest_args(cls, secret, ident, new=False): return secret, ident @classmethod - def is_password_too_long(cls, secret, err): + def is_secret_truncate_err(cls, secret, err): + if isinstance(err, PasswordTruncateError): + return True + if isinstance(err, PasswordSizeError): + return False 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): """ @@ -675,6 +663,32 @@ def _load_backend_mixin(mixin_cls, name, dryrun): # assert result.startswith(eff_ident) # return consteq(result, hash) + @classmethod + def _check_truncate_flag(cls, truncate_flag, secret): + assert cls.truncate_size is not None, "truncate_size must be set by subclass" + if truncate_flag and len(secret) > cls.truncate_size: + raise exc.PasswordTruncateError(cls) + + @classmethod + def _handle_w_truncate(cls, func, truncate_flag, secret, *args, **kwargs): + """ + Helper method to handle ValueError exceptions for passwords > 72 bytes. + Truncates password if needed and retries the operation. + """ + try: + return func(secret, *args, **kwargs) + except ValueError as err: + # bcrypt >= 5.0.0 will raise ValueError on passwords > 72 bytes + if not cls.is_secret_truncate_err(secret, err): + raise + cls._check_truncate_flag(truncate_flag, secret) + # silently truncate password to truncate_size bytes, and try again + return func(secret[:cls.truncate_size], *args, **kwargs) + + @classmethod + def verify(cls, secret, hash, **context): + return cls._handle_w_truncate(super().verify, cls.truncate_verify_reject, secret, hash, **context) + def _calc_checksum(self, secret): # bcrypt behavior: # secret must be bytes @@ -684,7 +698,7 @@ def _calc_checksum(self, secret): config = self._get_config(ident) if isinstance(config, str): config = config.encode("ascii") - hash = self.hash_password(_bcrypt, secret, config) + hash = self._handle_w_truncate(_bcrypt.hashpw, self.truncate_error, secret, config) assert isinstance(hash, bytes) if not hash.startswith(config) or len(hash) != len(config) + 31: raise uh.exc.CryptBackendError( From dcd1018084ccf24b40834d2e091e9388957fd198 Mon Sep 17 00:00:00 2001 From: mo7ty Date: Tue, 14 Oct 2025 16:01:12 +0100 Subject: [PATCH 11/12] Update `test_handlers_bcrypt/test_77_fuzz_input()` for BCrypt >= 5.0.0 * Update `_bcrypt_test.fuzz_verifier_bcrypt()/check_bcrypt()` to use `passlib.utils.handlers.GenericHandler.verify` --- tests/test_handlers_bcrypt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_handlers_bcrypt.py b/tests/test_handlers_bcrypt.py index b49c1fb..4ee9629 100644 --- a/tests/test_handlers_bcrypt.py +++ b/tests/test_handlers_bcrypt.py @@ -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.verify(secret, hash.decode('ascii')) except ValueError: raise ValueError(f"bcrypt rejected hash: {hash!r} (secret={secret!r})") From 4a4b855651033c24ee97820cf464aa7e2ebf6834 Mon Sep 17 00:00:00 2001 From: mo7ty Date: Wed, 15 Oct 2025 08:59:14 +0100 Subject: [PATCH 12/12] Update `passlib.utils.handlers.TruncateMixin` * Simplify and update `passlib.utils.handlers.TruncateMixin.using()` to also accept `truncate_verify_reject` * Add and use `passlib.utils.handlers.TruncateMixin.`[+_check_truncate_flag()+] and [+_check_verify_truncate_policy()+] helper methods * Update `tests.utils.HandlerCase.test_truncate_error_setting()` to cover `truncate_verify_reject` setting * Update `passlib.handlers.bcrypt._BcryptBackend` accordingly --- passlib/handlers/bcrypt.py | 20 +++++++++++--------- passlib/utils/handlers.py | 36 ++++++++++++++++++++++++++---------- tests/utils.py | 32 +++++++++++++++++++++----------- 3 files changed, 58 insertions(+), 30 deletions(-) diff --git a/passlib/handlers/bcrypt.py b/passlib/handlers/bcrypt.py index 89ec20a..00e18b5 100644 --- a/passlib/handlers/bcrypt.py +++ b/passlib/handlers/bcrypt.py @@ -20,7 +20,6 @@ from packaging.version import parse import passlib.utils.handlers as uh -from passlib import exc from passlib._logging import logger from passlib.crypto.digest import compile_hmac from passlib.exc import PasslibHashWarning, PasslibSecurityError, PasswordSizeError, PasswordTruncateError @@ -105,7 +104,7 @@ class _BcryptCommon( # type: ignore[misc] # PasswordHash # -------------------- name = "bcrypt" - setting_kwds: tuple[str, ...] = ("salt", "rounds", "ident", "truncate_error") + setting_kwds: tuple[str, ...] = ("salt", "rounds", "ident", "truncate_error", "truncate_verify_reject") # -------------------- # GenericHandler @@ -663,12 +662,6 @@ def _load_backend_mixin(mixin_cls, name, dryrun): # assert result.startswith(eff_ident) # return consteq(result, hash) - @classmethod - def _check_truncate_flag(cls, truncate_flag, secret): - assert cls.truncate_size is not None, "truncate_size must be set by subclass" - if truncate_flag and len(secret) > cls.truncate_size: - raise exc.PasswordTruncateError(cls) - @classmethod def _handle_w_truncate(cls, func, truncate_flag, secret, *args, **kwargs): """ @@ -748,6 +741,15 @@ class bcrypt(_NoBackend, _BcryptCommon): # type: ignore[misc] .. versionadded:: 1.7 + :param bool truncate_verify_reject: + 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_verify_reject=False`` will maintain backward compatibility by truncating long passwords silently. + Setting ``truncate_verify_reject=True`` will cause :meth:`~passlib.ifc.PasswordHash.verify` + to raise a :exc:`~passlib.exc.PasswordTruncateError` instead. + + .. versionadded:: 1.10 + :type relaxed: bool :param relaxed: By default, providing an invalid value for one of the other @@ -807,7 +809,7 @@ class _wrapped_bcrypt(bcrypt): """ setting_kwds = tuple( - elem for elem in bcrypt.setting_kwds if elem not in ["truncate_error"] + elem for elem in bcrypt.setting_kwds if elem not in ["truncate_error", "truncate_verify_reject"] ) truncate_size: int | None = None diff --git a/passlib/utils/handlers.py b/passlib/utils/handlers.py index ca8288d..6e78958 100644 --- a/passlib/utils/handlers.py +++ b/passlib/utils/handlers.py @@ -469,24 +469,40 @@ class TruncateMixin(MinimalHandler): truncate_verify_reject = False @classmethod - def using(cls, truncate_error=None, **kwds): + def using(cls, truncate_error=None, truncate_verify_reject=None, **kwds): subcls = super().using(**kwds) if truncate_error is not None: - truncate_error = as_bool(truncate_error, param="truncate_error") - if truncate_error is not None: - subcls.truncate_error = truncate_error + subcls.truncate_error = as_bool(truncate_error, param="truncate_error") + if truncate_verify_reject is not None: + subcls.truncate_verify_reject = as_bool(truncate_verify_reject, param="truncate_verify_reject") return subcls + @classmethod + def _check_truncate_flag(cls, truncate_flag, secret): + """Check if secret exceeds truncate_size when truncate_flag is enabled.""" + assert cls.truncate_size is not None, "truncate_size must be set by subclass" + if truncate_flag and len(secret) > cls.truncate_size: + raise exc.PasswordTruncateError(cls) + @classmethod def _check_truncate_policy(cls, secret): """ - make sure secret won't be truncated. - NOTE: this should only be called for .hash(), not for .verify(), - which should honor the .truncate_verify_reject policy. + Ensure secret won't be truncated during hashing. + + Uses the truncate_error policy to determine whether to raise an error + if the secret exceeds the maximum allowed length. """ - assert cls.truncate_size is not None, "truncate_size must be set by subclass" - if cls.truncate_error and len(secret) > cls.truncate_size: - raise exc.PasswordTruncateError(cls) + cls._check_truncate_flag(cls.truncate_error, secret) + + @classmethod + def _check_verify_truncate_policy(cls, secret): + """ + Ensure secret won't be truncated during verification. + + Uses the truncate_verify_reject policy to determine whether to raise an error + if the secret exceeds the maximum allowed length during verification. + """ + cls._check_truncate_flag(cls.truncate_verify_reject, secret) class GenericHandler(MinimalHandler): diff --git a/tests/utils.py b/tests/utils.py index 31a77fc..89b798a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1997,10 +1997,11 @@ def test_truncate_error_setting(self): validate 'truncate_error' setting & related attributes """ # If it doesn't have truncate_size set, - # it shouldn't support truncate_error + # it shouldn't support truncate_error or truncate_verify_reject hasher = self.handler if hasher.truncate_size is None: assert "truncate_error" not in hasher.setting_kwds + assert "truncate_verify_reject" not in hasher.setting_kwds return # if hasher defaults to silently truncating, @@ -2015,17 +2016,26 @@ def test_truncate_error_setting(self): assert hasher.truncate_error return - # test value parsing - def parse_value(value): - return hasher.using(truncate_error=value).truncate_error + # helper function to test value parsing for truncate settings + def test_truncate_setting_parsing(setting_name, current_value): + """Test that a truncate setting correctly parses various input values""" + def parse_value(value): + return getattr(hasher.using(**{setting_name: value}), setting_name) + + assert parse_value(None) == current_value + assert parse_value(True) is True + assert parse_value("true") is True + assert parse_value(False) is False + assert parse_value("false") is False + with pytest.raises(ValueError): + parse_value("xxx") - assert parse_value(None) == hasher.truncate_error - assert parse_value(True) is True - assert parse_value("true") is True - assert parse_value(False) is False - assert parse_value("false") is False - with pytest.raises(ValueError): - parse_value("xxx") + # test truncate_error value parsing + test_truncate_setting_parsing("truncate_error", hasher.truncate_error) + + # test truncate_verify_reject value parsing if supported + if "truncate_verify_reject" in hasher.setting_kwds: + test_truncate_setting_parsing("truncate_verify_reject", hasher.truncate_verify_reject) def test_secret_wo_truncate_size(self): """