SecuredKit

Опубліковано: · Оновлено: · автор:

Що таке bcrypt і чому MD5 мертвий для паролів

Що таке bcrypt, як працюють його сіль і cost factor, чому MD5-хеші паролів падають за години на одній GPU і як мігрувати таблицю з MD5 без скидань.

bcrypt проти md5 для хешування паролів — надійний алгоритм проти зламаного

bcrypt — це функція хешування паролів, яку 1999 року створили Нільс Провос і Девід Мазьєр. Вона автоматично додає сіль до кожного пароля й має cost factor, який задає, наскільки повільним буде кожен хеш, тож обсяг роботи росте разом із залізом. У MD5 немає ні того, ні іншого, і на одній RTX 4090 він виконується близько 164 млрд разів за секунду. Випадковий 8-символьний пароль, збережений у MD5, на цій карті падає в середньому за 5,6 години, а той самий пароль у bcrypt із cost 10 вимагатиме близько 18 000 років. Увесь аргумент зводиться до цього розриву, а далі мова про те, як bcrypt його досягає і як піти з MD5.

Чому MD5 мертвий для паролів

MD5 (RFC 1321, 1992 рік) задумували як швидкий дайджест повідомлень для цифрових підписів. Практичні колізії опублікували 2004 року, і це поховало його для підписів і сертифікатів. Але таблиці паролів у MD5 ламаються не через колізії. Їх губить швидкість, а ще те, що колонки з MD5-паролями зазвичай без солі, тож поширені паролі миттєво знаходяться одним пошуком у заздалегідь обчисленій таблиці.

Середній час, щоб знайти випадковий пароль на одній RTX 4090 (hashcat 6.2.6), перебравши половину простору ключів:

Випадковий парольMD5SHA-256bcrypt cost 10bcrypt cost 12
8 символів, друковані ASCII (95⁸)5,6 години1,8 дня~18 000 років~73 000 років
12 символів, лише малі літери (26¹²)3,4 дня25 днів~260 000 років~1 млн років
10 символів, літери й цифри (62¹⁰)30 днів221 день~2,3 млн років~9 млн років

Ці цифри справедливі лише для справді випадкових паролів. Паролі, які вигадали люди, атакують словниками з правилами, і проти MD5 прогін на 10 млрд кандидатів завершується приблизно за 0,06 секунди. Перехід на SHA-256 теж мало що дає: він приблизно всемеро повільніший за MD5, але це досі мільярди спроб за секунду, тому порада «просто візьміть SHA-256» ніколи не була рішенням.

Як працює bcrypt

bcrypt бере розклад ключів Blowfish і навмисно робить його дорогим. Ось що відбувається, коли ви викликаєте bcrypt.hash(password, 12):

password + fresh 128-bit random salt
        │
        ▼
EksBlowfishSetup(cost, salt, password)
  └─ repeat 2^cost times:          ← the deliberately slow part
       expand key state with password
       expand key state with salt
        │
        ▼
encrypt "OrpheanBeholderScryDoubt" 64 times with that state
        │
        ▼
"$2b$12$" + 22-char salt + 31-char hash   (60 characters, store as-is)

Кожен збережений хеш містить усе потрібне для перевірки:

$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LevH.6/Jwejc5/Hre
 │   │  └──── salt (22 chars) ────┘└──── hash (31 chars) ────┘
 │   └ cost: 2^12 = 4,096 rounds of key setup
 └ version

Перевірка читає версію, cost і сіль зі збереженого рядка, виконує те саме обчислення для спроби входу й порівнює результат. Окрема колонка для солі не потрібна, а порівняння бібліотека робить сама (bcrypt.compare() у Node).

Як обрати cost factor

Кожна одиниця cost подвоює час на хеш, і для вас, і для атакувальника. Мінімум OWASP — 10, а порада — ставити стільки, скільки дозволяє продуктивність сервера перевірки. Універсально правильного числа немає, бо все залежить від вашого процесора, тож міряйте на машині, яка обслуговуватиме входи:

import bcrypt from "bcrypt";

for (const cost of [10, 11, 12, 13]) {
  const start = performance.now();
  await bcrypt.hash("benchmark-only-password", cost);
  console.log(`cost ${cost}: ${Math.round(performance.now() - start)} ms`);
}

Обирайте найбільший cost, час якого вкладається у ваш бюджет на вхід за реалістичної конкурентності, а не на ноутбуці без навантаження. У Node нативний пакет bcrypt виконує хешування в пулі потоків libuv, де за замовчуванням 4 потоки, тож сплеск входів шикується в чергу. З тієї ж причини ендпоінт входу потребує обмеження частоти запитів: повільний хеш — це ціна, яку ви платите за кожну спробу, зокрема й атакувальника.

Коли згодом піднімете cost, наявні хеші оновляться під час наступного входу:

const COST = 12;

if (await bcrypt.compare(password, user.passwordHash)) {
  if (bcrypt.getRounds(user.passwordHash) < COST) {
    await db.users.updateHash(user.id, await bcrypt.hash(password, COST));
  }
}

Де bcrypt показує свій вік

bcrypt досі прийнятний, і OWASP його досі перелічує. Але в нього є три обмеження, про які варто знати, перш ніж обирати його для чогось нового.

Вхід обривається на 72 байтах. Більшість реалізацій мовчки ігнорують усе після 72-го байта, а в UTF-8 це може бути лише 18 емодзі або 36 кириличних літер. Okta наступила на це 2024 року, коли ключ кешу на основі bcrypt, що містив довге ім'я користувача, перестав залежати від пароля. Цей випадок і безпечну схему попереднього хешування я розбираю в гайді з хешування паролів із сіллю.

Префікси версій — це історія, а не вибір. $2a$ — оригінал, $2y$ з'явився в crypt_blowfish 2011 року як виправлення бага з розширенням знака, а $2b$ — виправлення OpenBSD 2014 року для переповнення довжини в дуже довгих паролях. Генеруйте $2b$, а під час перевірки приймайте й інші.

Пам'яті він використовує мало й фіксовано: робочий стан bcrypt займає близько 4 KiB, хоч який cost ви поставите. В Argon2id обсяг пам'яті на хеш задаєте ви (мінімум OWASP — 19 MiB), тож кожна спроба коштує атакувальнику і обчислень, і оперативної пам'яті. Для нової системи я б узяв Argon2id, а наявну таблицю на bcrypt із cost від 10 переводив би під час наступного рефакторингу автентифікації, а не як аварійне виправлення.

Міграція таблиці паролів з MD5

Перехешувати MD5 у bcrypt без відкритих паролів неможливо, а якщо чекати, поки кожен користувач увійде, неактивні акаунти роками лишатимуться вразливими. Підхід, який описує OWASP, працює у два кроки.

Спершу загорніть кожен наявний хеш уже сьогодні: зберігайте bcrypt(md5_hex) і позначте рядок як legacy. Тепер витік бази дасть атакувальнику bcrypt-хеші, а не MD5. Далі при кожному успішному вході замінюйте загорнутий хеш прямим:

import { createHash } from "node:crypto";

async function login(user, password) {
  if (user.hashScheme === "bcrypt(md5)") {
    const md5 = createHash("md5").update(password).digest("hex");
    if (!(await bcrypt.compare(md5, user.passwordHash))) return false;
    await db.users.update(user.id, {
      passwordHash: await bcrypt.hash(password, COST),
      hashScheme: "bcrypt",
    });
    return true;
  }
  return bcrypt.compare(password, user.passwordHash);
}

Загорнуті хеші слабші за прямі. Якщо несолені MD5-хеші ваших користувачів колись витекли деінде, атакувальник може перевіряти ці MD5-значення напряму проти обгортки (OWASP називає це password shucking). Сприймайте обгортку як тимчасовий захід і через кілька місяців примусово скиньте паролі тим акаунтам, які досі на ній.

Щоб побачити різницю у швидкості самостійно, введіть рядок у Генератор хешів: він рахує все локально на сторінці, без жодного запиту до сервера, і MD5-дайджест оновлюється з кожним натисканням клавіші. Потім вставте той самий рядок у Генератор bcrypt і посуньте повзунок cost. Він показує кількість ітерацій і оцінку часу для кожного кроку та розкладає отриманий хеш на версію, cost, сіль і контрольну суму. Якщо ви читаєте це з боку користувача, а не розробника, у статті скільки часу потрібно, щоб зламати пароль пояснено, що ці швидкості означають для пароля, який ви обираєте.

FAQ

Чи безпечно зберігати паролі в MD5 у 2026 році?

Ні. Одна RTX 4090 виконує близько 164 млрд MD5-хешів за секунду. Випадковий 8-символьний пароль падає в середньому за 5,6 години, а несолені MD5-хеші поширених паролів можна просто знайти в таблиці. Якщо вам дісталася таблиця з MD5, загорніть її в bcrypt чи Argon2id уже зараз і перехешовуйте під час входу.

Що таке bcrypt і як він працює?

bcrypt — адаптивна функція хешування паролів на основі налаштування ключа Blowfish. Кожен виклик генерує 128-бітну випадкову сіль, виконує дороге налаштування ключа 2^cost разів і повертає 60-символьний рядок із версією, cost, сіллю та хешем. Ви зберігаєте цей рядок і передаєте його в compare() під час входу.

Який cost factor для bcrypt ставити у 2026 році?

Щонайменше 10, за OWASP, і вище, якщо сервери дозволяють. Заміряйте bcrypt.hash() з cost від 10 до 13 на продакшн-залізі, оберіть найбільше значення, яке вкладається в затримку входу під навантаженням, і за допомогою getRounds() перехешовуйте старіші хеші, коли користувачі входять.

Чи кращий bcrypt за Argon2?

Для нового коду кращий Argon2id: він memory-hard і не має ліміту входу в 72 байти. bcrypt із cost від 10 досі вважається прийнятним, тож аварійна міграція не потрібна. Переводьте bcrypt-хеші на Argon2id під час входу, коли наступного разу працюватимете з автентифікацією.

Чи можна хешувати паролі через SHA-256?

Ні. У нього немає cost factor, і на одній RTX 4090 він видає близько 22 млрд хешів за секунду. Це приблизно всемеро повільніше за MD5, але атака словником однаково завершується майже миттєво. Залиште SHA-256 для контрольних сум, підписів і HMAC.

Спробуйте безкоштовний генератор паролів

Генеруйте надійні, безпечні паролі миттєво. 100% приватно, на стороні клієнта.

Відкрити генератор паролів