Тикет пришёл от разработчика, который писал импорт учёток из старой системы. Формулировка была такая: «кажется, у нас ломается верификация, но ломается она в плюс — подходят пароли, которых не было».
Ломалось не у него. Так работает bcrypt, и работает уже двадцать семь лет.
Что происходит с длинным паролем
bcrypt читает первые 72 байта пароля. Всё, что дальше, в вычислении не участвует. Не отбрасывается с ошибкой, не приводит к другому хешу — просто не читается.
Проще всего это увидеть на PHP, где password_hash — стандартный способ из документации (алгоритм задан явным PASSWORD_BCRYPT, чтобы он не поехал при смене умолчания):
$ php -r ' $p72 = str_repeat("A", 72); $p100 = str_repeat("A", 100); $h = password_hash($p72, PASSWORD_BCRYPT); echo "hash: $h\n"; echo "72 : ".var_export(password_verify($p72, $h), true)."\n"; echo "100 : ".var_export(password_verify($p100, $h), true)."\n"; ' hash: $2y$12$PbZr8al6MKXZcBYUsVUlte5tWqUdDUsDEr5yS3SWMmGieM/sQodoC 72 : true 100 : true
Хеш посчитан от 72 байт. Пароль из 100 символов к нему подошёл. Предупреждения не было ни при хешировании, ни при проверке.
Хвост может быть и другим по содержанию, не только длиннее:
$ php -r ' $h = password_hash(str_repeat("A",72), PASSWORD_BCRYPT); $a = str_repeat("A",72)."xxxxx"; $b = str_repeat("A",72)."СОВСЕМ ДРУГОЕ"; echo "A: len=".strlen($a)." verify=".var_export(password_verify($a,$h),true)."\n"; echo "B: len=".strlen($b)." verify=".var_export(password_verify($b,$h),true)."\n"; ' A: len=77 verify=true B: len=97 verify=true
С точки зрения bcrypt это один и тот же пароль.
Почему 72
bcrypt построен на блочном шифре Blowfish, у которого ключ разворачивается в таблицу подключей. Таблица P занимает 18 подключей по 4 байта — это ровно 72 байта. Сам Blowfish умеет и более длинные ключи, он их просто циклически повторяет; но Провос и Мазьер зафиксировали размер таблицы как жёсткую границу длины пароля, и всё, что за неё выходит, до функции не доходит.
Ограничение попало в статью 1999 года, в которой bcrypt и был предложен, и с тех пор не менялось: менять его — значит получить другой алгоритм и несовместимые хеши.
Байты, а не символы
72 — это байты. Латиница в UTF-8 — один байт на символ, и до лимита действительно надо дописать 72 знака. Кириллица — два байта на символ:
$ php -r ' foreach ([35,36,37] as $n) { $s = str_repeat("я", $n); printf("%d символов = %d байт%s\n", $n, strlen($s), strlen($s) > 72 ? " <- за границей" : ""); }' 35 символов = 70 байт 36 символов = 72 байт 37 символов = 74 байт <- за границей
Тридцать шесть символов — и лимит выбран целиком. Тридцать седьмой уже не участвует:
$ php -r ' $p36 = str_repeat("я",36); $h = password_hash($p36, PASSWORD_BCRYPT); echo "36 симв (72 байта): ".var_export(password_verify($p36,$h),true)."\n"; echo "36 симв + хвост : ".var_export(password_verify($p36."ДРУГОЕ",$h),true)."\n";' 36 симв (72 байта): true 36 симв + хвост : true
Это перестаёт быть экзотикой ровно в тот момент, когда вы начинаете рассказывать пользователям про парольные фразы. Фраза на кириллице из четырёх слов — это 30–45 символов, то есть 60–90 байт. Начиная с тридцать седьмого символа хвост перестаёт учитываться, и два разных пароля становятся одним:
$ php -r ' $base = "КорректнаяЛошадьБатарейкаСкрепкаДлинныйПароль"; $p1 = $base."ХвостОдин"; $p2 = $base."СовсемДругойХвост"; echo "p1 байт: ".strlen($p1)." p2 байт: ".strlen($p2)."\n"; $h = password_hash($p1, PASSWORD_BCRYPT); echo "verify(p2, hash(p1)) = ".var_export(password_verify($p2,$h),true)."\n"; ' p1 байт: 108 p2 байт: 124 verify(p2, hash(p1)) = true
Пользователь при этом уверен, что у него длинная фраза, — он и составил её по совету с вашей же страницы регистрации.
С эмодзи ещё теснее: одиночная кодовая точка — четыре байта, и таких знаков влезает восемнадцать. Составные, которые собираются из нескольких точек через соединители и модификаторы тона, съедают по 8–25 байт за один видимый символ, и до потолка их доходит от трёх до восьми.
Насколько это опасно
72 байта латиницы — заведомо больше, чем нужно: перебор двадцати случайных символов из печатного набора не решается имеющимися мощностями. Если пользователь придумал длинный пароль, его первые 72 байта почти наверняка достаточно стойкие, и обрезка не даёт атакующему ничего.
Проблема не в стойкости, а в том, что система ведёт себя не так, как показывает. Проявляется это в трёх местах, и все три — про эксплуатацию.
Менеджеры паролей. Сгенерированная строка на 100 символов сохраняется у пользователя целиком, а проверяется по первым 72. Пока всё в одной системе — незаметно. Появляется второй сервис с другой реализацией, которая режет длину по-своему или отвергает такой пароль целиком, — и пользователь получает «неверный пароль» при правильном пароле, а вы получаете тикет, который никто не может воспроизвести.
Миграция и сравнение реализаций. Одна библиотека молча берёт первые 72 байта, другая бросает исключение, а среди обёрток встречаются такие, которые обрезают строку до 72 символов ещё до кодирования в UTF-8, — и на кириллице дают третий результат. При переносе базы это всплывает как «часть пользователей не может войти» без всякой закономерности, потому что закономерность в кодировке их паролей.
Совпадение там, где его не ждут. Общий 72-байтовый префикс делает пароли неразличимыми. Сценарий надуманный ровно до тех пор, пока в компании не появляется корпоративный шаблон вроде длинного постоянного префикса плюс что-то в конце.
Библиотеки перестали молчать
За последние несколько лет часть реализаций перешла от тихой обрезки к явному отказу. Python-пакет bcrypt, начиная с ветки 4.x, не хеширует и не проверяет длинный пароль вообще:
$ python3 -c " import bcrypt h = bcrypt.hashpw(b'A'*72, bcrypt.gensalt(10)) print('72 ok:', bcrypt.checkpw(b'A'*72, h)) print('73 ok:', bcrypt.checkpw(b'A'*73, h)) " 72 ok: True Traceback (most recent call last): ValueError: password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
С точки зрения безопасности это правильно: лучше громкий отказ, чем тихое несоответствие. С точки зрения эксплуатации — это исключение в бою при обновлении зависимости, если раньше кто-то регистрировался с длинным паролем.
Так что перед обновлением стоит посмотреть, есть ли у вас вообще ограничение длины на форме. Обычно выясняется, что на форме ограничения нет, а в библиотеке хеширования — уже есть.
Как чинят
Ограничить длину принимаемого пароля — и при регистрации, и при аутентификации. Не 72 символа, а 72 байта, потому что считается именно это. Ограничение должно быть в валидации, одинаковой на клиенте и на сервере, и пользователю про него надо сказать — иначе он увидит немотивированный отказ. Верхняя граница нужна не для стойкости, а чтобы поведение системы совпадало с тем, что человек видит на экране.
Или предварительно хешировать. Пароль прогоняется через SHA-256, результат кодируется в base64, и в bcrypt уходит эта строка. Длина входа перестаёт иметь значение: на bcrypt всегда приходит ровно 44 байта, сколько бы ни ввёл пользователь.
Ключевое слово тут — base64, и вот почему. Сырой двоичный вывод SHA-256 подавать в bcrypt нельзя: в исторических реализациях на C пароль — это строка, оканчивающаяся нулевым байтом, и всё после первого нуля отбрасывалось. Нулевой байт в 32 случайных байтах — событие вовсе не редкое:
$ python3 -c "print('%.1f%%' % ((1-(255/256)**32)*100))" 11.8%
Примерно каждый восьмой пароль превратился бы в обрубок, а часть из них — в совсем короткий. Base64 даёт печатные символы, и вопрос снимается.
Проверять надо и то, как ведёт себя ваша библиотека сейчас: PHP 8 отказывается хешировать пароль с нулевым байтом, а python-пакет bcrypt обрабатывает его целиком. Но полагаться на это в коде, который переживёт смену зависимостей, я бы не стал.
Хешировать дважды одним bcrypt («bcrypt поверх bcrypt») смысла не имеет: результат — печатная строка длиной 60 байт, до лимита не дотягивает, но и проблему не решает, а стоимость удваивает.
Или взять алгоритм, у которого такого ограничения нет. Argon2id и scrypt принимают вход любой длины. Если система новая — вопрос на этом закрывается. Если система старая — переезд делается прозрачно: при следующем успешном входе пароль уже проверен, в этот момент он есть в открытом виде, и его можно перехешировать новым алгоритмом, записав рядом маркер версии. Через несколько месяцев остаются те, кто не заходил, и им назначают принудительную смену пароля.
Само по себе долгое хранение bcrypt-хешей — не авария. Алгоритм не сломан, и претензия к нему ровно одна — вот эти 72 байта.
Что посмотреть у себя
Три проверки, каждая отвечает на свой вопрос.
Есть ли на форме регистрации верхняя граница длины и совпадает ли она с тем, что валидирует сервер. Обычно они не совпадают.
У скольких учёток пароль вводился длиннее 72 байт. Напрямую этого из хеша не видно, но в логах приложения обычно есть длина введённой строки или хотя бы факт срабатывания валидатора. Если не пишется — начать писать длину (не пароль).
Все ли места, где вызывается хеширование, ограничивают длину. Их почти всегда больше одного: регистрация, смена пароля, сброс, импорт, сидинг тестовых данных, админка. Если хоть одно не ограничивает, останется путь, по которому в базу попадёт хеш обрезанного пароля.

