Skip to content

Commit b34798c

Browse files
committed
Fix mypy type checking issues
- Remove unused type ignore comments in bcrypt hasher - Update Makefile - Update type stubs for redis and ujson to latest versions
1 parent 3b08ba7 commit b34798c

File tree

3 files changed

+59
-49
lines changed

3 files changed

+59
-49
lines changed

Makefile

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ help:
55
@fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
66

77
clean: ## Removing cached python compiled files
8-
find . -name \*pyc | xargs rm -fv
9-
find . -name \*pyo | xargs rm -fv
10-
find . -name \*~ | xargs rm -fv
11-
find . -name __pycache__ | xargs rm -rfv
12-
find . -name .ruff_cache | xargs rm -rfv
8+
find . -name "*.pyc" -type f -delete
9+
find . -name "*.pyo" -type f -delete
10+
find . -name "*~" -type f -delete
11+
find . -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
12+
find . -name ".ruff_cache" -type d -exec rm -rf {} + 2>/dev/null || true
1313

1414
install: ## Install dependencies
1515
pip install -r requirements.txt

ellar/core/security/hashers/bcrypt.py

Lines changed: 51 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import math
33
import typing as t
44

5+
import bcrypt
56
from ellar.utils.crypto import RANDOM_STRING_CHARS
67
from 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)

requirements-tests.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ aiohttp == 3.10.5
22
anyio[trio] >= 3.2.1
33
argon2-cffi == 25.1.0
44
autoflake
5-
bcrypt; python_version >= '3.12'
5+
bcrypt; python_version >= '3.9'
66
click >= 8.1.7,<9.0.0,
77
email_validator >=1.1.1
88
itsdangerous >=1.1.0,<3.0.0
@@ -17,8 +17,8 @@ regex==2025.9.18
1717
ruff ==0.13.3
1818
types-dataclasses ==0.6.6
1919
types-orjson ==3.6.2
20-
types-redis ==4.6.0.20240903
20+
types-redis ==4.6.0.20241004
2121
# types
22-
types-ujson ==5.10.0.20250326
22+
types-ujson ==5.10.0.20250822
2323
ujson >= 4.0.1
2424
uvicorn[standard] == 0.30.6

0 commit comments

Comments
 (0)