Skip to content

Commit 3b08ba7

Browse files
committed
Fix bcrypt hashers for Python 3.13 compatibility
Add fallback handling for Python 3.13's strict 72-byte password limit enforcement in bcrypt. BCryptSHA256Hasher now manually pre-hashes with SHA256 when needed, and BCryptHasher explicitly truncates passwords to 72 bytes. Maintains backward compatibility across all Python versions.
1 parent 952f4dd commit 3b08ba7

File tree

1 file changed

+88
-1
lines changed

1 file changed

+88
-1
lines changed

ellar/core/security/hashers/bcrypt.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import hashlib
2+
import math
3+
import typing as t
4+
5+
from ellar.utils.crypto import RANDOM_STRING_CHARS
16
from passlib.hash import django_bcrypt, django_bcrypt_sha256
27

3-
from .base import BaseHasher
8+
from .base import BaseHasher, EncodingSalt, EncodingType
49

510

611
class BCryptSHA256Hasher(BaseHasher):
@@ -11,6 +16,9 @@ class BCryptSHA256Hasher(BaseHasher):
1116
must first install the bcrypt library. Please be warned that
1217
this library depends on native C code and might cause portability
1318
issues.
19+
20+
This hasher uses SHA256 to pre-hash passwords, allowing passwords
21+
of any length to be safely hashed without hitting bcrypt's 72-byte limit.
1422
"""
1523

1624
hasher = django_bcrypt_sha256
@@ -22,6 +30,55 @@ def _get_using_kwargs(self) -> dict:
2230
"rounds": self.rounds,
2331
}
2432

33+
def _sha256_hash(self, password: EncodingType) -> str:
34+
"""
35+
Hash password with SHA256 and return as hex string.
36+
This matches what passlib's django_bcrypt_sha256 does internally.
37+
"""
38+
if isinstance(password, str):
39+
password_bytes = password.encode("utf-8")
40+
else:
41+
password_bytes = password
42+
43+
return hashlib.sha256(password_bytes).hexdigest()
44+
45+
def encode(
46+
self, password: EncodingType, salt: EncodingSalt = None
47+
) -> t.Union[str, t.Any]:
48+
self._check_encode_args(password, salt)
49+
50+
default_salt_size = math.ceil(
51+
self.salt_entropy / math.log2(len(RANDOM_STRING_CHARS))
52+
)
53+
using_kw = {"default_salt_size": default_salt_size, "salt": salt}
54+
using_kw.update(self._get_using_kwargs())
55+
56+
# Try passlib first (works on Python < 3.13)
57+
try:
58+
return self.hasher.using(**using_kw).hash(password)
59+
except ValueError as e:
60+
# Python 3.13+ bcrypt enforces 72-byte limit before passlib can pre-hash
61+
# So we pre-hash manually and use the plain bcrypt hasher
62+
if "password cannot be longer than 72 bytes" in str(e):
63+
hashed = self._sha256_hash(password)
64+
return self.hasher.using(**using_kw).hash(hashed)
65+
raise
66+
67+
def verify(self, secret: EncodingType, hash_secret: str) -> bool:
68+
"""
69+
Verify secret against an existing hash.
70+
"""
71+
# Try passlib first (works on Python < 3.13)
72+
try:
73+
return self.hasher.verify(secret, hash_secret) # type:ignore[no-any-return]
74+
except ValueError as e:
75+
# Python 3.13+ bcrypt enforces 72-byte limit before passlib can pre-hash
76+
# So we pre-hash manually
77+
if "password cannot be longer than 72 bytes" in str(e):
78+
hashed = self._sha256_hash(secret)
79+
return self.hasher.verify(hashed, hash_secret) # type:ignore[no-any-return]
80+
raise
81+
2582
def decode(self, encoded: str) -> dict:
2683
algorithm, empty, algostr, work_factor, data = encoded.split("$", 4)
2784
assert algorithm == self.algorithm
@@ -54,3 +111,33 @@ class BCryptHasher(BCryptSHA256Hasher):
54111

55112
algorithm = "bcrypt"
56113
hasher = django_bcrypt
114+
115+
def encode(
116+
self, password: EncodingType, salt: EncodingSalt = None
117+
) -> t.Union[str, t.Any]:
118+
self._check_encode_args(password, salt)
119+
120+
# Truncate password to 72 bytes for bcrypt compatibility (Python 3.13+)
121+
if isinstance(password, str):
122+
password_bytes = password.encode("utf-8")[:72]
123+
else:
124+
password_bytes = password[:72]
125+
126+
default_salt_size = math.ceil(
127+
self.salt_entropy / math.log2(len(RANDOM_STRING_CHARS))
128+
)
129+
using_kw = {"default_salt_size": default_salt_size, "salt": salt}
130+
using_kw.update(self._get_using_kwargs())
131+
return self.hasher.using(**using_kw).hash(password_bytes)
132+
133+
def verify(self, secret: EncodingType, hash_secret: str) -> bool:
134+
"""
135+
Verify secret against an existing hash, truncating to 72 bytes.
136+
"""
137+
# Truncate secret to 72 bytes for bcrypt compatibility (Python 3.13+)
138+
if isinstance(secret, str):
139+
secret_bytes = secret.encode("utf-8")[:72]
140+
else:
141+
secret_bytes = secret[:72]
142+
143+
return self.hasher.verify(secret_bytes, hash_secret) # type:ignore[no-any-return]

0 commit comments

Comments
 (0)