Bcrypt, яким Laravel хешує паролі за замовчуванням, бере до уваги лише перші 72 байти пароля і мовчки відкидає решту. У PHP internals зараз одночасно обговорюють два RFC, що мають це виправити на рівні мови: PASSWORD_BCRYPT_SHA256 і PASSWORD_BCRYPT_WRAP. Обидва націлені на PHP 8.7, обидва ще в обговоренні, і дат голосування в жодного немає.
Перший RFC ми коротко згадували в огляді жовтневих пропозицій для PHP 8.7. Відтоді з'явився конкурент, тож час розібратися, у чому проблема і чим підходи відрізняються.
У чому проблема
Алгоритм bcrypt має жорстку межу в 72 байти. Усе, що далі, не впливає на хеш, тож два різні паролі з однаковими першими 72 байтами вважаються однаковими:
$hash = password_hash(str_repeat('a', 72) . 'перший', PASSWORD_BCRYPT);
password_verify(str_repeat('a', 72) . 'зовсім інший', $hash); // true
Для звичайного пароля з восьми-дванадцяти символів це не має значення. Але 72 байти - не 72 символи. Кирилична літера в UTF-8 займає два байти, емодзі - чотири, тож парольна фраза українською впирається в межу вже на 36 літерах. А ще небезпечніше, коли в password_hash() потрапляє не сам пароль, а склеєний рядок: префікс, ідентифікатор, інший хеш. RFC про виняток для довгих паролів наводить як приклад CVE-2025-68402 у FreshRSS: 64-символьний nonce перед 60-символьним хешем заповнював увесь 72-байтний простір статичними даними, і перевірка проходила з будь-яким паролем.
Друга вада - NUL-байт. На рівні crypt() і Blowfish рядок на деяких C-бібліотеках обрізається на першому \0. Сам password_hash() із PASSWORD_BCRYPT такий пароль уже не приймає і кидає ValueError ("Bcrypt password must not contain null character"). Проблема виринає тоді, коли розробник сам робить попереднє хешування з сирим бінарним виходом, наприклад hash('sha256', $password, true): у такому рядку NUL-байт трапляється часто.
Чому попередні спроби не пройшли
Обидва RFC починаються з переліку того, що вже пробували:
- Кидати помилку на довгих паролях. RFC Сьорда Лангкемпера пропонував
E_DEPRECATED у PHP 8.7 і ValueError у PHP 8.8 для паролів довших за 72 байти. Він досі у статусі чернетки: будь-яка зміна поведінки PASSWORD_BCRYPT, під яким у продакшн-базах лежать мільйони хешів, викликає опір, а відмова приймати раніше прийнятний вхід - сама по собі порушення сумісності.
- Виправити NUL-байти в реалізації Blowfish. PR #21675 досі відкритий - з тих самих міркувань.
- Зробити
PASSWORD_DEFAULT алгоритмом Argon2. Argon2 потребує зовнішньої залежності, яка є не в кожній збірці PHP, а bcrypt вбудований завжди і досі вважається одним із найкращих для інтерактивної автентифікації.
Тож обидва нові RFC йдуть іншим шляхом: не чіпають PASSWORD_BCRYPT, а додають окремий опційний алгоритм, який спершу хешує пароль швидкою функцією, а результат у текстовому кодуванні віддає bcrypt. Вхід bcrypt тоді завжди має фіксовану довжину і ніколи не містить NUL-байта.
PASSWORD_BCRYPT_SHA256: формат Passlib
RFC Сьорда Лангкемпера (реалізація - PR #24073) переносить у PHP алгоритм bcrypt_sha256 з Python-бібліотеки Passlib у його другій версії. Пароль проходить через HMAC-SHA256, де ключ - сіль bcrypt, 32 байти результату кодуються в Base64 (44 символи) і йдуть у bcrypt:
$hash = password_hash($password, PASSWORD_BCRYPT_SHA256);
// $bcrypt-sha256$v=2,t=2b,r=12$n79VH.0Q2TMWmt3Oqt9uku$Kq4Noyk3094Y2QlB8NdRT8SvGiI4ft2
password_verify($password, $hash); // true
Головний аргумент - не вигадувати нічого свого. Алгоритм уже пройшов публічну перевірку, а хеші, створені в PHP і Python, взаємозамінні: застосунки на двох мовах зі спільною базою користувачів можуть читати хеші одне одного. Вартість cost така сама, як у PASSWORD_BCRYPT (4-31, за замовчуванням 12), а password_verify(), password_needs_rehash() і password_get_info() працюють без змін.
PASSWORD_BCRYPT_WRAP: свій формат і суперечка про деталі
RFC Андрея Андреєва (Andrey Andreev) з PR #25040, який поки позначений як WIP, з'явився 29 жовтня і відкрито полемізує з першим. Конструкція та сама, деталі інші:
- дві функції попереднього хешування на вибір через опцію
prehash: cshake256 (за замовчуванням) і sha512 у вигляді HMAC;
- 54 байти результату в Base64 займають рівно 72 байти, тобто весь простір bcrypt;
- формат PHC Strings зі стандартним Base64 замість формату Passlib;
- ліміт пароля 1024 байти проти явного зловживання.
$hash = password_hash($password, PASSWORD_BCRYPT_WRAP, ['prehash' => 'sha512']);
// $bcrypt-wrap$v=0$cost=12,prehash=sha512$<сіль>$<хеш>
Розділ FAQ пояснює, чому автор вважає bcrypt-sha256 гіршим. SHA-256 - 32-бітна функція, яку дешево перебирати на GPU, тоді як 64-бітні SHA-512 і cSHAKE256 стають вузьким місцем для зломщика безкоштовно для захисника. Формат Passlib базується на застарілому Modular Crypt Format, несумісному з PHC. Сама Passlib, за даними RFC, роками стояла без супроводу, і власник пакета змінився лише наприкінці жовтня 2026 року.
Окремо RFC розбирає shucking (hash shucking): атаку, за якої зломщик ламає не весь bcrypt, а внутрішній швидкий хеш, підставляючи готові хеші з чужих витоків. Від неї рятує те, що внутрішній хеш залежить від унікального ключа. У bcrypt-wrap це сіль із префіксом bcrypt-wrap:0:, що прив'язує внутрішній хеш і до конкретного bcrypt, і до версії формату. bcrypt-sha256 у нинішній версії теж використовує сіль як ключ HMAC, але в RFC нагадують, що Passlib була вразлива до shucking до версії 1.7.3 2020 року.
Вибір між двома RFC - не стільки про безпеку, скільки про пріоритети: сумісність із наявною екосистемою проти формату, спроєктованого з нуля під сучасні стандарти. Хеші обох довші за звичайні 60 символів bcrypt (у bcrypt-wrap - 93-97 символів), але стандартна колонка password у Laravel - це string, тобто VARCHAR(255), тож місця вистачить.
Що робить Laravel сьогодні
Драйвер за замовчуванням у config/hashing.php - bcrypt, і BcryptHasher викликає password_hash() з PASSWORD_BCRYPT без жодного попереднього хешування. Перевірка довжини в ньому є, але вимкнена: опція limit береться з BCRYPT_LIMIT і за замовчуванням дорівнює null.
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 12),
'verify' => env('HASH_VERIFY', true),
'limit' => env('BCRYPT_LIMIT', null),
],
Якщо задати BCRYPT_LIMIT=72, Hash::make() для довшого рядка кине InvalidArgumentException. Перевірка рахує саме байти (strlen()), і вона стосується тільки make(): на Hash::check() уже збережених хешів вона не впливає. Пароль із NUL-байтом Hash::make() і без цього не прийме: ValueError від PHP перехоплюється і перетворюється на RuntimeException з не надто очевидним текстом "Bcrypt hashing not supported."
Пастка - у валідації. Правило max:72 рахує символи через mb_strlen(), а не байти, тож для кириличного пароля воно пропустить до 144 байтів. Якщо хочете чесно обмежити довжину на реєстрації, перевіряйте байти:
'password' => [
'required',
Password::defaults(),
function (string $attribute, mixed $value, Closure $fail) {
if (strlen($value) > 72) {
$fail('Пароль задовгий: максимум 72 байти.');
}
},
],
Коли має сенс argon2id
Laravel уже підтримує драйвери argon і argon2id, і вони обмеження в 72 байти не мають. Перевірте, чи ваша збірка PHP вміє Argon2: password_algos() має повернути argon2id. У Docker-образах і на хостингах це не гарантовано, саме тому PHP досі не зробив Argon2 типовим.
Перехід на HASH_DRIVER=argon2id у наявному застосунку потребує уваги. З HASH_VERIFY=true, як у типовому конфігу, Argon2IdHasher::check() на старому bcrypt-хеші кидає RuntimeException ("This password does not use the Argon2id algorithm."), тобто користувачі зі старими паролями не зможуть увійти. На час міграції verify треба вимкнути: тоді password_verify() перевірить хеш будь-якого алгоритму, а rehash_on_login перехешує пароль у новий формат при наступному вході.
Що робити зараз
- Не склеюйте нічого з паролем перед
Hash::make(). Префікси, ідентифікатори й інші хеші з'їдають 72-байтний бюджет і можуть зробити хеш незалежним від пароля.
- Перевіряйте довжину в байтах, а не в символах, якщо обмежуєте пароль на реєстрації, і подумайте про
BCRYPT_LIMIT=72 як про другий запобіжник.
- Не робіть власного попереднього хешування з
hash(..., true): сирий бінарний вихід із NUL-байтами - саме та помилка, від якої захищають обидва RFC.
- Argon2id - розумний вибір для нового проєкту, якщо збірка PHP його підтримує. Для наявного - тільки з продуманою міграцією.
- Стежте за обговоренням. Обидва RFC змінюються:
bcrypt-sha256 оновився 1 листопада, bcrypt-wrap - 2 листопада. Якщо вам є що сказати, гілки про bcrypt-sha256 і про bcrypt-wrap відкриті. А після ухвалення одного з них ще знадобиться підтримка нового алгоритму у фреймворку - про неї поки нічого не відомо.