Coverage for src/lexigram/auth/authn/security.py: 81%

146 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 00:58 +0800

1"""Password security utilities using Passlib with a lightweight fallback.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import base64 

7import hashlib 

8import importlib 

9from pathlib import Path 

10from types import ModuleType 

11from typing import TYPE_CHECKING, Any, cast 

12 

13from lexigram.contracts.auth import PasswordHasherProtocol, PasswordPolicyProtocol 

14from lexigram.logging import get_logger 

15 

16if TYPE_CHECKING: 

17 from lexigram.auth.config import PasswordConfig 

18 

19__all__ = [ 

20 "DUMMY_PASSWORD_HASH", 

21 "PasswordHasher", 

22 "PasswordPolicy", 

23 "UnknownHashError", 

24] 

25 

26logger = get_logger(__name__) 

27 

28try: 

29 _bcrypt: ModuleType | None = importlib.import_module("bcrypt") 

30except ImportError: 

31 _bcrypt = None 

32bcrypt_available = _bcrypt is not None 

33 

34if not bcrypt_available: 

35 logger.warning("bcrypt not available - password hashing will be limited") 

36 

37try: 

38 pyotp: ModuleType | None = importlib.import_module("pyotp") 

39except ImportError: 

40 pyotp = None 

41pyotp_available = pyotp is not None 

42 

43UnknownHashError = Exception 

44 

45 

46_MAX_PASSWORD_BYTES = 72 

47_WARN_PASSWORD_BYTES = 64 

48_DEFAULT_BCRYPT_ROUNDS = 12 

49 

50 

51def _prehash(password: str) -> str: 

52 """Pre-hash *password* with SHA-256 to avoid bcrypt's 72-byte truncation. 

53 

54 The SHA-256 digest (32 bytes) is base64-encoded to a 44-byte ASCII string, 

55 which is always well within bcrypt's 72-byte limit. This must be applied 

56 consistently in both :meth:`PasswordHasher.hash` and 

57 :meth:`PasswordHasher.verify` so that hashes computed with pre-hashing can 

58 be verified correctly. 

59 

60 Args: 

61 password: Plain-text password string. 

62 

63 Returns: 

64 Base64-encoded SHA-256 digest of the UTF-8 encoded password. 

65 """ 

66 return base64.b64encode(hashlib.sha256(password.encode()).digest()).decode() 

67 

68 

69def _prepare_password_bytes(password: str) -> bytes: 

70 """Return the bytes that bcrypt should hash for *password*. 

71 

72 * Passwords **≤ 64 bytes**: encoded directly — no transformation. 

73 * Passwords **> 64 bytes and ≤ 72 bytes**: a structured warning is emitted 

74 because these are in the borderline range where bcrypt's limit is close. 

75 * Passwords **> 72 bytes**: pre-hashed with SHA-256 via :func:`_prehash` 

76 so that bcrypt sees a 44-byte ASCII string rather than silently 

77 truncating the input. 

78 

79 Args: 

80 password: Plain-text password string. 

81 

82 Returns: 

83 Bytes ready to pass to ``bcrypt.hashpw`` / ``bcrypt.checkpw``. 

84 

85 Raises: 

86 RuntimeError: If bcrypt is not installed. 

87 """ 

88 if not bcrypt_available: 

89 raise RuntimeError("bcrypt library is not available") 

90 

91 password_bytes = password.encode("utf-8") 

92 byte_length = len(password_bytes) 

93 

94 if byte_length > _MAX_PASSWORD_BYTES: 

95 # Pre-hash to avoid silent bcrypt truncation. 

96 return _prehash(password).encode("ascii") 

97 

98 if byte_length > _WARN_PASSWORD_BYTES: 

99 logger.warning( 

100 "password_near_bcrypt_limit", 

101 byte_length=byte_length, 

102 limit=_MAX_PASSWORD_BYTES, 

103 message=( 

104 "Password is between 64 and 72 bytes; " 

105 "it is close to bcrypt's 72-byte truncation limit." 

106 ), 

107 ) 

108 

109 return password_bytes 

110 

111 

112class PasswordHasher(PasswordHasherProtocol): 

113 """Bcrypt password hasher implementing the PasswordHasherProtocol. 

114 

115 Provides secure password hashing using bcrypt with UTF-8 aware truncation. 

116 The bcrypt cost factor is per-instance (``rounds``) so the configured 

117 value is honored both at construction and by the container singleton. 

118 """ 

119 

120 MAX_PASSWORD_BYTES = _MAX_PASSWORD_BYTES 

121 

122 def __init__(self, rounds: int = _DEFAULT_BCRYPT_ROUNDS) -> None: 

123 self._rounds = rounds 

124 

125 async def hash(self, password: str) -> str: 

126 """Hash a password using bcrypt with UTF-8 aware truncation. 

127 

128 Args: 

129 password: Plain text password. 

130 

131 Returns: 

132 Bcrypt hash string (``$2b$<cost>$...``) using the configured 

133 cost factor. 

134 

135 Raises: 

136 RuntimeError: If bcrypt is not available. 

137 """ 

138 if not bcrypt_available: 

139 raise RuntimeError("bcrypt library is not available") 

140 

141 password_bytes = _prepare_password_bytes(password) 

142 

143 def _hash_sync(pwd_bytes: bytes) -> str: 

144 bcrypt = cast("Any", _bcrypt) 

145 salt = bcrypt.gensalt(rounds=self._rounds) 

146 hashed = bcrypt.hashpw(pwd_bytes, salt) 

147 return str(hashed.decode("ascii")) 

148 

149 return await asyncio.to_thread(_hash_sync, password_bytes) 

150 

151 async def verify(self, password: str, hashed_password: str | bytes) -> bool: 

152 """Verify a password against its hash asynchronously. 

153 

154 Args: 

155 password: Plain text password. 

156 hashed_password: Stored hash to compare against. 

157 

158 Returns: 

159 True if the password matches the hash, False otherwise. 

160 

161 Raises: 

162 RuntimeError: If bcrypt is not available. 

163 """ 

164 if not bcrypt_available: 

165 raise RuntimeError("bcrypt library is not available") 

166 

167 def _verify_sync(pwd_bytes: bytes, hash_bytes: bytes) -> bool: 

168 try: 

169 bcrypt = cast("Any", _bcrypt) 

170 return bool(bcrypt.checkpw(pwd_bytes, hash_bytes)) 

171 except ValueError: 

172 return False 

173 

174 password_bytes = _prepare_password_bytes(password) 

175 

176 if isinstance(hashed_password, str): 

177 hashed_bytes = hashed_password.encode("ascii") 

178 else: 

179 hashed_bytes = hashed_password 

180 

181 return await asyncio.to_thread(_verify_sync, password_bytes, hashed_bytes) 

182 

183 def needs_rehash(self, hashed_password: str) -> bool: 

184 """Compare the stored hash's cost to the configured target. 

185 

186 Parses the self-describing bcrypt prefix (``$2b$<cost>$...``); returns 

187 ``True`` when the stored cost is below the configured ``rounds``. 

188 Unparseable or unknown formats return ``True`` (fail-closed) — safe 

189 because rehashing only ever runs after a successful ``verify()``. 

190 

191 Args: 

192 hashed_password: Stored bcrypt hash string. 

193 

194 Returns: 

195 True when the hash should be re-computed at the current cost. 

196 """ 

197 if not isinstance(hashed_password, str) or not hashed_password: 

198 return True 

199 try: 

200 parts = hashed_password.split("$") 

201 if len(parts) >= 3 and parts[1] in ("2a", "2b", "2y", "2x"): 

202 cost = int(parts[2]) 

203 return cost < self._rounds 

204 except (ValueError, IndexError, TypeError): 

205 logger.warning( 

206 "password_hash_cost_unparseable", 

207 hash_prefix=hashed_password[:7], 

208 ) 

209 return True 

210 

211 async def rehash_if_needed( 

212 self, 

213 password: str, 

214 hashed_password: str | None, 

215 ) -> str | None: 

216 """Rehash the password if needed.""" 

217 if not hashed_password: 

218 return None 

219 if self.needs_rehash(hashed_password): 

220 return await self.hash(password) 

221 return None 

222 

223 

224# A constant dummy hash used to ensure password verification is always executed 

225# to prevent timing side-channels that reveal whether a username exists. 

226# This must be a valid bcrypt hash string to avoid verification errors. 

227# 

228# SECURITY: This is intentionally weak (not a real hash) - it is used ONLY to 

229# ensure that password verification runs for non-existent users, preventing 

230# attackers from distinguishing between "user not found" and "wrong password" 

231# via timing attacks. The hash is valid bcrypt format but the password that 

232# produces it is unknown/impossible to guess. 

233DUMMY_PASSWORD_HASH = "$2b$12$OMAqo55i5DcmvOMAqo55i5DcmvOMAqo55i5DcmvOMAqo55i5Dcmv" # noqa: S105 # timing-equalization dummy, no real password matches 

234 

235 

236class PasswordPolicy(PasswordPolicyProtocol): 

237 """Password policy configuration. 

238 

239 Lazy-loads common passwords file only when needed. 

240 Implements PasswordPolicyProtocol for dependency injection compatibility. 

241 """ 

242 

243 _DEFAULT_COMMON_PASSWORDS: set[str] | None = None 

244 

245 def __init__( 

246 self, 

247 min_length: int = 8, # NIST SP 800-63B minimum 

248 max_length: int = 128, 

249 require_uppercase: bool = True, 

250 require_lowercase: bool = True, 

251 require_digits: bool = False, 

252 require_special: bool = False, 

253 prevent_common: bool = True, # NIST SP 800-63B: check against breach lists 

254 prevent_reuse: bool = False, 

255 history_size: int = 5, 

256 common_passwords_file: str | None = None, 

257 banned_patterns: list[str] | None = None, 

258 ): 

259 self.min_length = min_length 

260 self.max_length = max_length 

261 self.require_uppercase = require_uppercase 

262 self.require_lowercase = require_lowercase 

263 self.require_digits = require_digits 

264 self.require_special = require_special 

265 self.prevent_common = prevent_common 

266 self.prevent_reuse = prevent_reuse 

267 self.history_size = history_size 

268 self._common_passwords_file = common_passwords_file 

269 self._common_passwords: set[str] | None = None 

270 self.banned_patterns: list[str] = banned_patterns or [] 

271 

272 @classmethod 

273 def from_config(cls, config: PasswordConfig) -> PasswordPolicy: 

274 """Build a ``PasswordPolicy`` from a ``PasswordConfig`` dataclass. 

275 

276 Args: 

277 config: Password complexity configuration from ``AuthConfig.password``. 

278 

279 Returns: 

280 A configured :class:`PasswordPolicy` instance. 

281 """ 

282 return cls( 

283 min_length=config.min_length, 

284 max_length=config.max_length, 

285 require_uppercase=config.require_uppercase, 

286 require_lowercase=config.require_lowercase, 

287 require_digits=config.require_digits, 

288 require_special=config.require_special, 

289 banned_patterns=list(config.banned_patterns), 

290 ) 

291 

292 def _load_common_passwords(self, file_path: str | None) -> set[str]: 

293 """Load common passwords from file (lazy-loaded).""" 

294 if not file_path: 

295 if PasswordPolicy._DEFAULT_COMMON_PASSWORDS is None: 

296 PasswordPolicy._DEFAULT_COMMON_PASSWORDS = { 

297 "password", 

298 "password1", 

299 "password123", 

300 "123456", 

301 "123456789", 

302 "12345678", 

303 "1234567890", 

304 "qwerty", 

305 "qwerty123", 

306 "abc123", 

307 "letmein", 

308 "monkey", 

309 "dragon", 

310 "master", 

311 "sunshine", 

312 "princess", 

313 "welcome", 

314 "shadow", 

315 "superman", 

316 "michael", 

317 "football", 

318 "baseball", 

319 "iloveyou", 

320 "trustno1", 

321 "hunter2", 

322 "admin", 

323 "admin123", 

324 "administrator", 

325 "root", 

326 "toor", 

327 "passw0rd", 

328 "p@ssword", 

329 "p@ssw0rd", 

330 "pass@word", 

331 "test", 

332 "test123", 

333 "demo", 

334 "demo123", 

335 "guest", 

336 "guest123", 

337 "login", 

338 "login123", 

339 "changeme", 

340 "change_me", 

341 "default", 

342 "secret", 

343 "secret123", 

344 "temp", 

345 "temp123", 

346 "temporary", 

347 "letmein1", 

348 "letmein123", 

349 "qwertyuiop", 

350 "asdfghjkl", 

351 "zxcvbnm", 

352 "1q2w3e4r", 

353 "1q2w3e", 

354 "11111111", 

355 "22222222", 

356 "33333333", 

357 "00000000", 

358 "111111111", 

359 "password2", 

360 "password1!", 

361 "p@ssword1", 

362 "admin@123", 

363 "welcome1", 

364 "welcome@1", 

365 "hello123", 

366 "summer2023", 

367 "winter2023", 

368 "spring2023", 

369 "autumn2023", 

370 "january1", 

371 "february1", 

372 "march2023", 

373 "welcome123", 

374 } 

375 return PasswordPolicy._DEFAULT_COMMON_PASSWORDS 

376 

377 try: 

378 with Path(file_path).open() as f: 

379 return {line.strip().lower() for line in f if line.strip()} 

380 except FileNotFoundError: 

381 return set() 

382 

383 def _get_common_passwords(self) -> set[str]: 

384 """Return common passwords set, loading lazily on first access.""" 

385 if self._common_passwords is None: 

386 self._common_passwords = self._load_common_passwords( 

387 self._common_passwords_file 

388 ) 

389 return self._common_passwords 

390 

391 def validate(self, password: str) -> None: 

392 """Validate password against policy. 

393 

394 Args: 

395 password: Plain text password to validate. 

396 

397 Raises: 

398 ValueError: If the password violates the policy. 

399 """ 

400 errors = [] 

401 

402 # Length check 

403 if len(password) < self.min_length: 

404 errors.append( 

405 f"Password must be at least {self.min_length} characters long", 

406 ) 

407 

408 if len(password) > self.max_length: 

409 errors.append( 

410 f"Password must be at most {self.max_length} characters long", 

411 ) 

412 

413 # Banned pattern check 

414 lower = password.lower() 

415 for pattern in self.banned_patterns: 

416 if pattern.lower() in lower: 

417 errors.append( 

418 f"Password must not contain the substring '{pattern}'", 

419 ) 

420 

421 # Character requirements 

422 if self.require_uppercase and not any(c.isupper() for c in password): 

423 errors.append("Password must contain at least one uppercase letter") 

424 

425 if self.require_lowercase and not any(c.islower() for c in password): 

426 errors.append("Password must contain at least one lowercase letter") 

427 

428 if self.require_digits and not any(c.isdigit() for c in password): 

429 errors.append("Password must contain at least one digit") 

430 

431 if self.require_special and not any(not c.isalnum() for c in password): 

432 errors.append("Password must contain at least one special character") 

433 

434 # Common password check 

435 if self.prevent_common and password.lower() in self._get_common_passwords(): 

436 errors.append("Password is too common, please choose a different one") 

437 

438 if errors: 

439 raise ValueError("; ".join(errors)) 

440 

441 def is_valid(self, password: str) -> bool: 

442 """Return True if the password satisfies the policy without raising. 

443 

444 Args: 

445 password: Plain text password. 

446 

447 Returns: 

448 True if valid, False otherwise. 

449 """ 

450 try: 

451 self.validate(password) 

452 return True 

453 except ValueError: 

454 return False