1+ import hashlib
2+ import math
3+ import typing as t
4+
5+ from ellar .utils .crypto import RANDOM_STRING_CHARS
16from passlib .hash import django_bcrypt , django_bcrypt_sha256
27
3- from .base import BaseHasher
8+ from .base import BaseHasher , EncodingSalt , EncodingType
49
510
611class 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