Ошибка bcrypt «более 72 байт»: почему падают и короткие пароли
Два совершенно разных сбоя выдают одно и то же сообщение, и только один из них вообще связан с паролем.
Если пароль действительно длиннее предела bcrypt в 72 байта, bcrypt читает первые 72 байта и отбрасывает остальное. Мы вычислили хеш двух паролей по 82 байта, у которых совпадали первые 72 байта, на фиксированной соли. Оба дали $2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S, а bcrypt.compareSync(p2, hash(p1)) вернул true. Вторым паролем можно войти в аккаунт первого.
Если же пароль заведомо короткий, а ошибка всё равно появляется:
password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
(«пароль не может быть длиннее 72 байт, при необходимости обрежьте его вручную»), то сообщение врёт о причине. В связке passlib 1.7.4 с bcrypt 5.0.0 её вызывает пароль длиной 14 байт.
Виновник — фиксированный 255-байтовый самопроверочный зонд внутри passlib. Он отрабатывает один раз, при инициализации бэкенда, ещё до того, как ваш пароль дойдёт до вызова вычисления хеша. bcrypt 5.0.0 отклоняет зонд, исключение вылетает наружу, и вы читаете претензию к паролю, который никто не вводил.
Monkey patch __about__, который занимает всю выдачу по этой ошибке, её не лечит. Мы перезапустили его в чистом процессе, применив патч до import passlib, — ValueError вернулся без изменений.
Диагностика за 30 секунд: какой случай ваш
| Ваш пароль | Когда возникает ошибка | Первопричина | Куда идти |
|---|---|---|---|
| Длиннее 72 байт | При вызове вычисления хеша | Он действительно слишком длинный. bcrypt 5.0 бросает исключение, bcrypt 4.x молча обрезает | Разделы 2 и 3 |
| Короче 72 байт, используется passlib | На первом вызове в процессе | 255-байтовый зонд passlib. К вашему паролю отношения не имеет | Раздел 4 |
| Содержит китайские, японские символы или эмодзи | Выглядит коротким, но таковым не является | Символы — это не байты | Раздел 3 |
| Стал падать после обновления зависимостей | После деплоя | Ломающее изменение в bcrypt 5.0 | Разделы 4 и 5 |
Если это вторая строка, переходите сразу к разделу 4. Следующие два раздела вам ничем не помогут, а решение там совсем другое.
Что предел bcrypt в 72 байта делает с паролем
Почему bcrypt останавливается на 72
bcrypt построен на Blowfish и подаёт пароль внутрь как ключ Blowfish. Blowfish разворачивает ключ в P-массив из 18 подключей по 32 бита каждый. Это 18 × 4 = 72 байта ключевого материала, а цикл разворачивания возвращается к началу ключа, как только заполнит все 18 слотов.
То есть потолок структурный. Это не ленивая реализация и не настраиваемый буфер, который кто-то забыл увеличить. У любой корректной реализации bcrypt на любой платформе тот же предел, поэтому число 72 встречается одинаково в Python, Node, Go, Java и PHP.
Два разных пароля, один хеш
Обрезка пароля в bcrypt — это свойство безопасности, а не мелкое неудобство с длиной.
На bcryptjs 3.0.3 с фиксированной солью $2a$10$abcdefghijklmnopqrstuv мы вычислили хеш двух паролей по 82 байта каждый:
| Пароль | Значение | Байты |
|---|---|---|
| p1 | "A"×72 + "XXXXXXXXXX" | 82 |
| p2 | "A"×72 + "ZZZZZZZZZZ" | 82 |
Оба дали одинаковый дайджест:
$2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S
Два разных пароля, один хеш: true. Отсюда напрямую следует:
bcrypt.compareSync(p2, hash(p1)) // true
Злоумышленник, знающий первые 72 байта длинной passphrase, может дописать после них что угодно и пройти аутентификацию. Каждый байт за границей добавляет ровно ноль к стойкости сохранённого хеша, как бы тщательно пользователи его ни выбирали. Если нужно сверить уже имеющийся хеш с кандидатом-паролем, не собирая для этого скрипт, можно сгенерировать и проверить хеши bcrypt прямо в браузере и увидеть то же поведение своими глазами.
Где именно проходит граница
Мы сузили границу побайтово, сохраняя общий префикс и меняя ровно один байт после него:
| Совпадающих байт префикса | Различие в байте N+1 | Хеш совпал? |
|---|---|---|
| 70 | 71 | false |
| 71 | 72 | false |
| 72 | 73 | true |
| 73 | 74 | true |
Байт 72 ещё учитывается, байт 73 — уже нет. Плавного затухания и частичного подмешивания тут нет, так что проверить это на своей библиотеке локально дёшево.
Символы — это не байты
bcrypt считает байты UTF-8, а пользователи набирают символы. Для ASCII эти два числа совпадают, и проблема всплывает тогда, когда команда выходит за пределы англоязычного рынка.
| Тип символов | Пример | Байт на символ | 72 байта — это |
|---|---|---|---|
| Латиница ASCII | A | 1 | 72 символа |
| Китайские иероглифы | 密 | 3 | 24 символа |
| Японская кана | あ | 3 | 24 символа |
| Эмодзи | 🔒 | 4 | 18 символов |
| Кириллица | я | 2 | 36 символов |
| Немецкие умляуты | ü | 2 | 36 символов |
Мы подтвердили обе крайности: у китайского пароля различия после 24-го символа игнорируются (true), у пароля из эмодзи — после 18-го (true).
Passphrase из 25 китайских иероглифов выглядит в поле ввода вполне солидно. А черту она уже перешла. Пользователь, выбравший 20 эмодзи, превысил предел на два символа, и никто ему об этом не скажет.
Как измерить длину в байтах в своём коде
Проверки длины, написанные по количеству символов, будут проходить, когда само значение уже слишком длинное. Измеряйте байты:
# Python
len(pw.encode("utf-8"))
// Node.js
Buffer.byteLength(pw, "utf8")
// Go
len([]byte(pw))
В браузерах, где нет Buffer, то же число даёт new TextEncoder().encode(pw).length. Поставьте эту проверку перед вызовом вычисления хеша и возвращайте нормальное сообщение о валидации вместо того, чтобы библиотека решала за вас в три часа ночи. Если заодно пересматриваете политику минимальной длины, материал о том, как на самом деле измеряют стойкость пароля, разбирает, что правило длины даёт, а что нет.
Почему падают и короткие пароли: 255-байтовый зонд passlib
Из-за этого случая большинство и идёт в поисковики: пароль из четырнадцати символов, а библиотека утверждает, что он длиннее 72 байт.
Как это воспроизвести
Три строки на Python 3.14.5 с bcrypt 5.0.0 и passlib 1.7.4:
from passlib.hash import bcrypt
bcrypt.hash("short-password") # 14 байт
# ValueError: password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
На входе четырнадцать байт, на выходе претензия про 72. Ошибка passlib с bcrypt настоящая, но число в ней описывает совсем другое.
Полный стек вызовов
Последовательность прослежена по исходникам passlib 1.7.4:
- Первый вызов запускает инициализацию бэкенда:
_calc_checksum→_stub_requires_backend()→set_backend(). _load_backend_mixinчитаетbcrypt.__about__.__version__. Атрибута нет, поэтому возбуждаетсяAttributeError. passlib его проглатывает и печатает(trapped) error reading bcrypt version.- Инициализация продолжается в
_finalize_backend_mixin(passlib/handlers/bcrypt.py:421), который вызываетdetect_wrap_bug(IDENT_2A). detect_wrap_bug(тот же файл,:378) проверяет фиксированный 255-байтовый зонд.- bcrypt 5.0.0 бросает
ValueErrorна всё, что длиннее 72 байт, и зонд подрывается сам на себе. - Исключение доходит до места вашего вызова. Вы видите сообщение про 72 байта, которое никогда не относилось к вашему вводу.
Вся последовательность выполняется один раз на процесс, при первом вычислении или проверке хеша. Поэтому сбой так надёжно воспроизводится и совершенно не зависит от того, что вы передаёте на вход.
Как выглядит сам зонд
secret = (b"0123456789" * 26)[:255]
Эта константа взята из бага с заворачиванием ключа в BSD-реализации bcrypt, раскрытого Openwall в 2012 году: длинные ключи заворачивались по кругу и схлопывались в более слабые хеши. passlib на старте проверяет, нет ли этого дефекта в только что загруженном бэкенде, и отказывается доверять бэкенду, который им страдает.
detect_wrap_bug — не баг passlib. Это защитный код, который делает то, ради чего написан, на тестовом векторе, остававшемся корректным больше десяти лет. Изменилось другое: bcrypt 5.0.0 теперь считает 255-байтовый ввод ошибкой, а не данными для вычисления хеша, и проходивший ранее самотест превращается в неперехватываемый. Столкновение двух библиотек разбирается в обсуждении pyca/bcrypt в issue #1082.
Почему патч __about__ не помогает
Поищите эту ошибку — и вам раз за разом объяснят, что bcrypt удалил __about__, а возврат атрибута чинит passlib. Обе половины этого утверждения неверны:
| Версия | hasattr(bcrypt, "__about__") | Печатает trapped-предупреждение | passlib работает |
|---|---|---|---|
| bcrypt 5.0.0 | False | Да | Нет (ValueError) |
| bcrypt 4.3.0 | False | Да | Да |
В bcrypt 4.3.0 тоже нет __about__. Он печатает ту же строку (trapped) error reading bcrypt version. И passlib на нём работает без единой жалобы. Значит, отсутствующий атрибут — не та граница, что разделяет рабочее и сломанное. Граница проходит по изменению поведения ValueError в 5.0.0.
А раз так, популярный патч работать не может — и не работает:
import bcrypt, types
bcrypt.__about__ = types.SimpleNamespace(__version__=bcrypt.__version__) # до импорта passlib
from passlib.hash import bcrypt as pl
pl.hash("short-password")
# по-прежнему ValueError: password cannot be longer than 72 bytes, ...
Мы прогнали это в чистом процессе, применив патч до import passlib, — именно затем, чтобы никто не списал сбой на порядок импортов. Он всё равно падает. Единственное, чего добивается патч, — глушит безобидное предупреждение. 255-байтовый зонд из шага 4 — отдельная стадия, которая к __about__ изначально не обращалась, и детонирует он в любом случае.
Что изменилось в bcrypt 5.0
Ломающее изменение bcrypt 5.0 — это одна строка поведения с широким радиусом поражения:
| Ввод | bcrypt 4.3.0 | bcrypt 5.0.0 |
|---|---|---|
| 72 байта | OK | OK |
| 73 байта | OK (молча обрезано) | ValueError |
| 100 байт | OK (молча обрезано) | ValueError |
| 255 байт | OK (молча обрезано) | ValueError |
Обрезка в колонке 4.x — не фигура речи. На 4.3.0 hash(73 байта) и hash(100 байт), построенные из общего префикса, выходят одинаковыми: true.
Так что bcrypt 5.0 здесь — более правильная библиотека. Тихо выбрасывать ключевой материал хуже, чем отказаться продолжать, а отказ продолжать — это то, что и должна делать библиотека вычисления хешей, когда не может честно обработать полученный вход. Обновление от этого безболезненным не становится. Код, который годами молча терял байты, теперь бросает исключение, а если этот код спрятан за passlib, то бросает его ещё до того, как ваш ввод вообще участвует в деле.
Из этой таблицы следуют две вещи, и приходятся они на разные команды. Если вы вызываете bcrypt напрямую, обновление заметно: вы получаете исключение при регистрации или входе, в своём же коде, со стеком, указывающим на ваш собственный вызов. Добавьте перед ним проверку длины в байтах — и за вечер всё закрыто.
Если же вы ходите через passlib, обновление незаметно до того момента, когда оно становится тотальным. Масштаб сбоя не пропорционален доле пользователей с длинными паролями, потому что пользовательский ввод в нём вообще не участвует. В процессе падают все вычисления и все проверки хеша, начиная с первого вызова, — на кодовой базе, где в работе с паролями ничего не менялось. Поэтому история выглядит как инцидент деплоя, а не как баг-репорт, и поэтому текст ошибки отправляет людей искать не там.
Как это чинить
Если код менять можно
Откажитесь от passlib и вызывайте bcrypt напрямую. Последний релиз passlib — 1.7.4, и проект давно молчит, так что на проекте, которому нужен только bcrypt, этот слой даёт очень мало:
import bcrypt
password = "correct horse battery staple".encode("utf-8")
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
bcrypt.checkpw(password, hashed) # True
hashpw и checkpw принимают байты, поэтому кодируйте на границе, а остальной код пусть продолжает работать со str. Нет ни определения бэкенда, ни самопроверочного зонда, который упадёт на пароле, которого вы не передавали. Если хочется посмотреть на получившийся хеш глазами или проверить тот, что выдало приложение, генератор bcrypt работает целиком в браузере. У серверов, использующих bcrypt для HTTP Basic Auth, то же структурное ограничение проявляется в другом формате файла; его разбирает руководство по htpasswd.
Если менять код прямо сейчас нельзя
Зафиксируйте версию ниже 5:
bcrypt<5
Мы проверили bcrypt 4.3.0 с passlib 1.7.4 — работает. Но отдавайте себе отчёт в том, что вы купили. Это жгут, а не лечение. Вы остаётесь на версии, которая с длинным паролем в bcrypt молча выбрасывает байты, то есть на той самой проблеме, ради устранения которой выпускали 5.0. Поставьте на этот пин дату и запланируйте переезд.
Если ваши пользователи действительно вводят длинные passphrase
Сначала вычислите SHA-256 от пароля, закодируйте дайджест в Base64 и уже это отдайте bcrypt:
import base64, hashlib, bcrypt
def prehash(password: str) -> bytes:
return base64.b64encode(hashlib.sha256(password.encode("utf-8")).digest())
hashed = bcrypt.hashpw(prehash(pw), bcrypt.gensalt(rounds=12))
bcrypt.checkpw(prehash(pw), hashed)
На выходе всегда 44 байта, с солидным запасом меньше 72, какой бы длины ни был вход. И это возвращает свойство, разрушенное обрезкой: два пароля по 82 байта из вступительного раздела, пропущенные через такую схему, дают checkpw(prehash(p2), hash(prehash(p1))) = False. Коллизия исчезла.
Шаг с Base64 выполняет вполне реальную работу, так что выкидывать его не надо. Сырой дайджест SHA-256 — это произвольные двоичные данные, в которых могут встретиться байты NUL, а реализации bcrypt обрабатывают их несогласованно. Base64 даёт строку ASCII фиксированной длины без NUL. Применяйте одну и ту же функцию и при регистрации, и при входе, иначе все существующие хеши перестанут проходить проверку.
Чего делать не стоит
Два соблазнительных хода, оба вредные.
Monkey patch __about__ не работает. Измерение — в разделе 4. Если кто-то в команде собирается вставить его в код, эти четыре строки сэкономят ему вечер.
Обрезать самому через pw[:72] хуже, чем не делать ничего. Это превращает громкий сбой обратно в тихий и заново создаёт коллизию из раздела 2 уже в вашем собственном коде. Вы вручную воспроизведёте то самое поведение, ради устранения которого выпустили bcrypt 5.0, только, в отличие от версии библиотеки, ваша реализация никого и никогда не предупредит. Нужны длинные пароли — считайте хеш предварительно. Не нужны — проверяйте длину в байтах и отклоняйте с внятным сообщением.
Что делать с хешами, которые уже в базе
Какие записи затронуты
Только те аккаунты, владельцы которых регистрировались с паролем длиннее 72 байт. Для большинства потребительских продуктов множество небольшое: в чисто ASCII-аудитории это обычно любители длинных passphrase. Для продуктов, где пользователи вводят китайские, японские символы или эмодзи, работает раздел 3, и затронутое множество может оказаться заметно больше, чем подсказывает аудит, не считающий байты.
Опознать эти строки по хешам нельзя. Дайджест bcrypt имеет фиксированную ширину и не хранит никаких следов того, какой длины был вход. Если вы логировали длину пароля при регистрации, этот лог и есть ваша единственная опись. У большинства команд такого лога нет, а восстановить его задним числом невозможно, так что планируйте исходя из незнания, а не из списка.
Пересчитать разом не получится
Открытого текста нет — в этом весь смысл хранения хешей. Значит, миграция может быть только ленивой: обновляйте каждый аккаунт при следующей успешной аутентификации его владельца, пока открытый текст ненадолго оказался в памяти.
def login(user, password: str) -> bool:
if not verify_legacy(password, user.password_hash):
return False
if needs_rehash(user.password_hash):
user.password_hash = hash_new_scheme(password)
save(user)
return True
Сначала проверка по старой схеме и только потом пересчёт. Если поменять эти два шага местами, сохранённый хеш будет переписан до того, как вы убедились в правильности пароля. Храните рядом с каждым хешем идентификатор схемы, чтобы needs_rehash был сравнением поля, а не догадкой, и будьте готовы к длинному хвосту спящих аккаунтов, которые не заходят никогда. До новой схемы такие аккаунты добираются при сбросе пароля, а не принудительным пересчётом.
Когда полная миграция оправданна
Если вы и так пишете путь ленивого пересчёта, дешевле момента сменить лежащий под ним алгоритм у вас уже не будет. В Argon2id потолка в 72 байта нет, а подробное сравнение Argon2id и bcrypt разбирает, когда переход себя окупает, а когда остаться на bcrypt — правильное решение. Сверять параметры стоит по OWASP Password Storage Cheat Sheet.
Не начинайте миграцию только из-за этой ошибки. Если ваши пароли уверенно укладываются в 72 байта, bcrypt остаётся здравым выбором, а вашу проблему уже решил раздел 6.
FAQ
Почему bcrypt пишет, что мой пароль длиннее 72 байт, хотя он короткий?
Потому что сообщение относится к внутреннему зонду passlib, а не к вашему паролю. На первом вызове passlib запускает detect_wrap_bug с фиксированной тестовой строкой в 255 байт. bcrypt 5.0.0 бросает ValueError на всё длиннее 72 байт, зонд падает, и ошибка всплывает в месте вашего вызова. Достаточно пароля в 14 байт.
bcrypt правда игнорирует всё после 72 байт?
Да, bcrypt полностью отбрасывает всё после 72 байт. Два пароля по 82 байта с совпадающими первыми 72 байтами дают идентичный хеш $2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S, и каждый проходит проверку против хеша другого. Граница точная: различие в байте 72 меняет хеш, различие в байте 73 — уже нет.
Предел в 72 байта — это проблема безопасности?
Да, предел в 72 байта опасен для длинных passphrase. Кто знает первые 72 байта, может дописать произвольные байты и пройти аутентификацию, так что каждый байт сверх предела не даёт ничего. Для паролей короче 72 байт не меняется ничего. Предварительный SHA-256 снимает риск, если длинный ввод должен учитываться целиком.
Сколько символов в 72 байтах?
Число символов в 72 байтах зависит от кодировки. 72 буквы ASCII, 36 символов кириллицы или с умляутами, 24 китайских иероглифа, 24 знака японской каны или 18 эмодзи. bcrypt считает байты UTF-8, а не символы, поэтому измеряйте через len(pw.encode("utf-8")) в Python или Buffer.byteLength(pw, "utf8") в Node.
Исправляет ли патч __about__ ошибку passlib?
Нет, патч __about__ не исправляет ошибку passlib. Мы применили его до import passlib в чистом процессе, и ValueError всё равно сработал. У bcrypt 4.3.0 тоже нет __about__, и с passlib он работает, что доказывает: отсутствующий атрибут ни при чём. Патч лишь глушит предупреждение (trapped) error reading bcrypt version.
Стоит ли откатить bcrypt ниже 5.0?
Как временная мера — да, откат bcrypt ниже 5.0 помогает. bcrypt 4.3.0 с passlib 1.7.4 работает. Но 4.x молча обрезает всё после 72 байт, а именно от этого поведения и уходили в 5.0, так что считайте пин временным и переходите на прямые вызовы bcrypt.
Можно я просто сам обрежу пароль до 72 байт?
Не обрезайте пароль до 72 байт вручную. pw[:72] заново создаёт описанную выше коллизию внутри вашего кода, тихо и без единого предупреждения библиотеки. Либо считайте предварительный SHA-256 с Base64, чтобы длинные вводы оставались различимыми, либо проверяйте длину в байтах заранее и отклоняйте с внятным сообщением об ошибке.
Что будет с паролями, чьи хеши посчитаны до того, как я это исправил?
Пароли с хешами, посчитанными раньше, продолжат проходить проверку, потому что путь проверки обрезает так же, как обрезал путь вычисления. Ослаблены только аккаунты, зарегистрированные с паролями длиннее 72 байт, и пересчитать их без открытого текста нельзя. Пересчитывайте лениво при следующем успешном входе, а спящие аккаунты закрывайте через сброс пароля.