"""Bcrypt password hashing для DB-auth (#2550 — foundation, эпик #2549). bcrypt тихо обрезает пароли длиннее 72 байт (UTF-8) — это silent-truncation дыра (два разных пароля с общим 72-байтовым префиксом хешируются одинаково). `hash_password` явно ловит это и падает с ValueError вместо тихого поведения. `verify_password` на длинном пароле возвращает False (не raise) — сравнение паролей не должно ронять запрос авторизации. """ from __future__ import annotations import logging import bcrypt logger = logging.getLogger(__name__) _BCRYPT_MAX_BYTES = 72 _BCRYPT_ROUNDS = 12 def hash_password(plain: str) -> str: """Хеширует пароль через bcrypt (rounds=12). Raises: ValueError: пустой пароль или пароль длиннее 72 байт в UTF-8 (bcrypt тихо обрезает — недопустимо, см. модульный docstring). """ if not plain: raise ValueError("password must not be empty") encoded = plain.encode("utf-8") if len(encoded) > _BCRYPT_MAX_BYTES: raise ValueError( f"password too long: {len(encoded)} bytes (bcrypt max {_BCRYPT_MAX_BYTES})" ) salt = bcrypt.gensalt(rounds=_BCRYPT_ROUNDS) hashed = bcrypt.hashpw(encoded, salt) return hashed.decode("utf-8") def verify_password(plain: str, hashed: str) -> bool: """Сверяет пароль с bcrypt-хешем. Пустой пароль или пароль длиннее 72 байт в UTF-8 → False (не raise — verify — это false/true проверка на этапе логина, а не валидация ввода). """ if not plain or not hashed: return False encoded = plain.encode("utf-8") if len(encoded) > _BCRYPT_MAX_BYTES: return False try: return bcrypt.checkpw(encoded, hashed.encode("utf-8")) except (ValueError, TypeError) as e: # Malformed hash (напр. не-bcrypt строка в БД) — не должно ронять login. logger.warning("verify_password: malformed hash rejected: %s", e) return False