Тикет пришёл от разработчика, который писал импорт учёток из старой системы. Формулировка была такая: «кажется, у нас ломается верификация, но ломается она в плюс — подходят пароли, которых не было».

Ломалось не у него. Так работает 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 байт. Напрямую этого из хеша не видно, но в логах приложения обычно есть длина введённой строки или хотя бы факт срабатывания валидатора. Если не пишется — начать писать длину (не пароль).

Все ли места, где вызывается хеширование, ограничивают длину. Их почти всегда больше одного: регистрация, смена пароля, сброс, импорт, сидинг тестовых данных, админка. Если хоть одно не ограничивает, останется путь, по которому в базу попадёт хеш обрезанного пароля.