2025, Nov 10 09:02

Аутентификация Flask с паролями WordPress 6.8+: проверка хэшей $wp через HMAC‑SHA384 и bcrypt 2y

После обновления WordPress 6.8+ во Flask ломается проверка паролей. Даем код: хэши $wp с HMAC‑SHA384 и bcrypt 2y, ветвление со старым phpass для смешанных баз.

Аутентификация Flask по паролям из WordPress 6.8+ может внезапно перестать работать после обновления. Причина проста: WordPress изменил способ хранения хэшей паролей. Если раньше создавались хэши с префиксом $P$ (phpass), то в WordPress 6.8+ записываются строки, начинающиеся с $wp и содержащие внутри хэш bcrypt с идентификатором 2y. Если ваш Python‑код проверял только старый формат, он будет отвергать новый до тех пор, пока вы не адаптируете проверку.

Постановка задачи

Здесь — минимальный пример старого подхода, который работал с хэшами $P$ (phpass). Логика простая: загрузить сохранённый хэш, проверить открытый пароль и вернуть результат. Имена переменных отличаются, но поведение полностью соответствует исходному.

from passlib.hash import phpass
class Gatekeeper:
    def __init__(self, app):
        self.app = app
    def check_login(self, user_email, plain_secret):
        stored_hash = self.fetch_hash(user_email)
        # stored_hash выглядел так: $P$...
        # plain_secret был в открытом виде, например "something"
        if not phpass.verify(plain_secret, stored_hash):
            return False, Reply.invalid_auth(self.app.site_url)
        return True, None

После обновления попытка перейти на универсальную проверку через, например, flask_bcrypt, приводит к ошибкам вроде "Invalid salt", если подать строку вида $wp2y$10$... Новые хэши WordPress — это не обычные строки bcrypt; у них есть специальный префикс и этап предварительного хеширования, который нужно воспроизвести при проверке.

Что изменилось в WordPress 6.8+

В механизме паролей WordPress 6.8.1 добавлено предварительное хеширование перед bcrypt. Вычисляется HMAC‑SHA384 по обрезанному паролю с фиксированным ключом "wp-sha384". Затем запускается bcrypt с идентификатором 2y, а результат дополняется префиксом $wp, чтобы отличать его от «чистого» bcrypt. Этот префикс — явный маркер того, что пароль использует новую схему.

Решение: проверять по схеме WordPress 6.8+

Чтобы авторизовывать из Flask, воспроизведите конвейер WordPress 6.8+ на стороне Python. Сначала посчитайте HMAC‑SHA384 от пароля‑кандидата с ключом "wp-sha384", затем проверьте получившийся прехэш с частью bcrypt в сохранённой строке, предварительно убрав префикс $wp. Это повторяет логику WordPress и снимает ограничение bcrypt на 72 байта входа, не нарушая совместимость с их форматом.

import hmac
import hashlib
from typing import Union
from passlib.hash import bcrypt
def wp_pre_digest(secret: bytes) -> bytes:
    keyed = hmac.new('wp-sha384'.encode('utf-8'), digestmod=hashlib.sha384)
    keyed.update(secret)
    return keyed.digest()
def wp_hash_build(secret: Union[bytes, str]) -> str:
    if isinstance(secret, str):
        secret = secret.encode('utf-8')
    pre = wp_pre_digest(secret)
    baked = bcrypt.using(rounds=10, ident='2y').hash(pre)
    return '$wp' + baked
def wp_check_secret(secret: Union[bytes, str], combined_hash: bytes) -> bool:
    if combined_hash[:3] != '$wp':
        raise ValueError('not WordPress >= 6.8 password hash')
    peeled = combined_hash[3:]
    if isinstance(secret, str):
        secret = secret.encode('utf-8')
    pre = wp_pre_digest(secret)
    return bcrypt.verify(pre, peeled)

Если в базе смешаны старые и новые хэши, ветвитесь по префиксу. Сохраняйте текущую проверку phpass для $P$, а для $wp используйте новую схему. Так вы сможете аутентифицировать пользователей из обеих «эпох» без принудительной миграции.

from passlib.hash import phpass
class Gatekeeper:
    def __init__(self, app):
        self.app = app
    def check_login(self, user_email, plain_secret):
        stored_hash = self.fetch_hash(user_email)
        try:
            if stored_hash.startswith('$wp'):
                ok = wp_check_secret(plain_secret, stored_hash)
            else:
                ok = phpass.verify(plain_secret, stored_hash)
        except Exception:
            ok = False
        if not ok:
            return False, Reply.invalid_auth(self.app.site_url)
        return True, None

Почему это важно

Проверка пароля — это не только выбор функции хеширования; важно воспроизвести точный формат и последовательность шагов системы, которая породила хэш. В WordPress 6.8+ осознанно добавили прехэш и собственный префикс $wp, чтобы лучше обрабатывать длину пароля и однозначно отличать получающиеся строки. Игнорирование этих деталей приводит к ложным ошибкам вроде "Invalid salt" или, что хуже, к отказам в приёме пароля, которые выглядят как неверные учётные данные.

Выводы

Когда вашему сервису Flask нужно проверять пароли из WordPress 6.8+, смотрите на сохранённую строку и выбирайте подходящий проверяющий. Для наследуемых хэшей $P продолжайте использовать проверку phpass. Для нового формата $wp...2y$10$ сначала сделайте прехэш HMAC‑SHA384 с ключом "wp-sha384", а затем проверьте его через bcrypt 2y. Такое согласование с внутренней логикой WordPress возвращает работоспособность входа без изменений в модели данных и позволяет безболезненно обслуживать «смешанные» таблицы пользователей во время обновлений.

Статья основана на вопросе на StackOverflow от Pythonic007 и ответе от President James K. Polk.