22import math
33import typing as t
44
5+ import bcrypt
56from ellar .utils .crypto import RANDOM_STRING_CHARS
67from passlib .hash import django_bcrypt , django_bcrypt_sha256
78
@@ -30,18 +31,6 @@ def _get_using_kwargs(self) -> dict:
3031 "rounds" : self .rounds ,
3132 }
3233
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-
4534 def encode (
4635 self , password : EncodingType , salt : EncodingSalt = None
4736 ) -> t .Union [str , t .Any ]:
@@ -53,31 +42,44 @@ def encode(
5342 using_kw = {"default_salt_size" : default_salt_size , "salt" : salt }
5443 using_kw .update (self ._get_using_kwargs ())
5544
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
45+ # Avoid passlib's backend long-secret detection which raises on Python 3.13+
46+ # Pre-hash the secret with SHA256 and then use plain django_bcrypt,
47+ # rewriting the prefix to bcrypt_sha256 for compatibility.
48+ if isinstance (password , str ):
49+ secret_bytes = password .encode ("utf-8" )
50+ else :
51+ secret_bytes = password
52+
53+ digest_hex = hashlib .sha256 (secret_bytes ).hexdigest ().encode ("ascii" )
54+
55+ if salt is not None :
56+ salt_str = (
57+ salt .decode ("ascii" )
58+ if isinstance (salt , (bytes , bytearray ))
59+ else str (salt )
60+ )
61+ salt_full = f"$2b${ self .rounds :02d} ${ salt_str } " .encode ("ascii" )
62+ else :
63+ salt_full = bcrypt .gensalt (self .rounds )
64+
65+ hashed = bcrypt .hashpw (digest_hex , salt_full )
66+ return f"bcrypt_sha256${ hashed .decode ('ascii' )} "
6667
6768 def verify (self , secret : EncodingType , hash_secret : str ) -> bool :
6869 """
6970 Verify secret against an existing hash.
7071 """
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
72+ # Verify by pre-hashing secret and delegating to django_bcrypt
73+ if isinstance (secret , str ):
74+ secret_bytes = secret .encode ("utf-8" )
75+ else :
76+ secret_bytes = secret
77+
78+ digest_hex = hashlib .sha256 (secret_bytes ).hexdigest ().encode ("ascii" )
79+ if not hash_secret .startswith ("bcrypt_sha256$" ):
80+ return False
81+ hashed = hash_secret [len ("bcrypt_sha256$" ) :].encode ("ascii" )
82+ return bcrypt .checkpw (digest_hex , hashed )
8183
8284 def decode (self , encoded : str ) -> dict :
8385 algorithm , empty , algostr , work_factor , data = encoded .split ("$" , 4 )
@@ -117,27 +119,35 @@ def encode(
117119 ) -> t .Union [str , t .Any ]:
118120 self ._check_encode_args (password , salt )
119121
120- # Truncate password to 72 bytes for bcrypt compatibility (Python 3.13+)
122+ # Truncate password to 72 bytes for bcrypt compatibility
121123 if isinstance (password , str ):
122124 password_bytes = password .encode ("utf-8" )[:72 ]
123125 else :
124126 password_bytes = password [:72 ]
125127
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 )
128+ if salt is not None :
129+ salt_str = (
130+ salt .decode ("ascii" )
131+ if isinstance (salt , (bytes , bytearray ))
132+ else str (salt )
133+ )
134+ salt_full = f"$2b${ self .rounds :02d} ${ salt_str } " .encode ("ascii" )
135+ else :
136+ salt_full = bcrypt .gensalt (self .rounds )
137+
138+ hashed = bcrypt .hashpw (password_bytes , salt_full )
139+ return f"bcrypt${ hashed .decode ('ascii' )} "
132140
133141 def verify (self , secret : EncodingType , hash_secret : str ) -> bool :
134142 """
135143 Verify secret against an existing hash, truncating to 72 bytes.
136144 """
137- # Truncate secret to 72 bytes for bcrypt compatibility (Python 3.13+)
138145 if isinstance (secret , str ):
139146 secret_bytes = secret .encode ("utf-8" )[:72 ]
140147 else :
141148 secret_bytes = secret [:72 ]
142149
143- return self .hasher .verify (secret_bytes , hash_secret ) # type:ignore[no-any-return]
150+ if not hash_secret .startswith ("bcrypt$" ):
151+ return False
152+ hashed = hash_secret [len ("bcrypt$" ) :].encode ("ascii" )
153+ return bcrypt .checkpw (secret_bytes , hashed )
0 commit comments