1. «Зашифруємо пароль» — хибна мета
Ми вже зафіксували головний принцип: серверові не потрібен пароль «назад», йому потрібна лише безпечна перевірка збігу. Тепер важливо розрізнити дві моделі, які новачки найчастіше змішують: зворотне шифрування і необоротну перевірку пароля.
Коли ви тільки починаєте, дуже легко сказати: «Пароль — це секрет. Отже, його потрібно зашифрувати». Звучить логічно: секрети ж шифрують. Але з паролями проблема інша: нам не потрібно отримувати пароль назад, нам потрібно перевіряти збіг. Це тонка, але дуже важлива різниця: вона повністю змінює техніку зберігання.
Давайте на мить уявімо, що ви — сервер. До вас приходить користувач і каже: «Ось мій пароль». Вам потрібно відповісти: «Так, це той самий пароль» або «Ні, не той». І ось ключовий момент: серверові не потрібно знати початковий пароль у явному вигляді ні зараз, ні через тиждень, ні через рік. Серверові потрібно вміти виконати перевірку так, щоб навіть у разі витоку сховища зловмисник не отримав «список паролів усіх користувачів», а отримав «набір значень, які дуже важко перетворити назад на паролі».
Звідси випливає важливе формулювання мети:
Мета зберігання пароля — безпечна перевірка збігу, а не відновлення початкового рядка.
Щоб закріпити матеріал, порівняймо два підходи у вигляді маленької таблиці.
| Питання | Шифрування | Хешування для паролів |
|---|---|---|
| Чи можна отримати початковий пароль назад? | Так (якщо є ключ) | Ні (і це добре) |
| Що зберігається на сервері? | Зашифрований пароль + (десь) ключ | Хеш + параметри (і зазвичай сіль) |
| Що буде в разі витоку бази? | Якщо витече ключ — паролі «розкриті одразу» | Паролі не розкриваються напряму, потрібен перебір |
| Яка правильна мета? | Сховати вміст, але зберегти зворотність | Прибрати зворотність і залишити лише перевірку |
Тут немає думки, що шифрування завжди погане. Шифрування — чудовий інструмент, просто не для паролів. А далі нам знадобиться ще один рівень захисту: навіть «просто хешування» не завжди достатнє, і для паролів потрібен спеціальний клас функцій — адаптивні односторонні функції.
2. Шифрування: зворотність і ключ
Шифрування — це технологія, у якій зворотність закладена заздалегідь. Якщо у вас є ключ, ви можете виконати encrypt(plainText) і отримати «нечитабельну кашу», а потім виконати decrypt(cipherText) і відновити початковий текст. У цьому немає нічого поганого — це буквально сенс шифрування. Але для пароля нам не потрібна операція decrypt, і ось чому це важливо саме для вас як бекенд-розробника.
Уявімо, що ви все-таки вирішили зберігати пароль «у зашифрованому вигляді». Тоді десь у системі має бути ключ. І цей ключ має бути доступний застосунку, інакше він не зможе розшифрувати пароль і порівняти його з тим, що ввів користувач. Тобто у вас зʼявляється «суперсекрет», який відкриває всі паролі одразу. У реальному світі цей секрет або опиниться поруч — у конфігурації, у змінній середовища, у сховищі секретів, — або його викрадуть іншим способом. А далі — неприємна математика: один ключ → доступ до всіх паролів.
Для демонстрації саме ментальної моделі, без справжньої криптографії та без небезпечних «навчальних AES-прикладів», які потім хтось копіює в прод, можна показати ідею через простий інтерфейс:
public interface Encryptor {
// Шифруємо початковий текст у "нечитабельний" вигляд (зворотно, якщо є ключ)
String encrypt(String plainText);
// Розшифровуємо назад (для паролів це якраз зайва/небезпечна операція)
String decrypt(String cipherText);
}
Якщо ви обираєте модель «зашифрувати пароль», то неминуче приходите до думки: «нам потрібен decrypt». А тепер чесно: навіщо серверові вміти діставати пароль назад? Щоб… що? Щоб випадково вивести його в лог? Щоб «допомогти користувачеві згадати»? (Спойлер: так робити не можна.) Щоб переслати в підтримку? (Ще гірше.)
У паролів інша правильна модель: сервер зберігає не те, що можна розшифрувати, а те, що можна лише перевірити. Тому замість схеми «encrypt/decrypt» нам потрібна схема «hash + verify».
Наглядно це можна зобразити ось так:
flowchart LR
%% Ідея: шифрування передбачає зворотність (є decrypt)
subgraph ENC["ENCRYPTION"]
P1["пароль"] -->|encrypt| C["шифротекст"]
C -->|decrypt| P2["пароль"]
end
flowchart LR
%% Ідея: під час хешування для паролів ми нічого не повертаємо назад, а лише перевіряємо збіг
subgraph HASH["HASHING"]
P3["пароль"] -->|hash| S["збережене значення"]
P4["пароль"] --> V["перевірка"]
S --> V
V --> R["true/false"]
end
І саме другий варіант — те, що нам потрібно.
3. Хешування: перевіряємо збіг, не намагаючись «дізнатися пароль»
Хешування в найпростішому сенсі — це перетворення даних у фіксоване значення, зазвичай рядок або байти, яке зручно порівнювати й зберігати. Важливо: хеш не призначений для відновлення початкового значення. В ідеальному світі ви не можете взяти хеш і «розкрутити» його назад у пароль. Ви можете лише пробувати вгадати: «а якщо пароль був ось таким, чи збігся б хеш?».
І ось тут зʼявляється правильна модель перевірки:
1) користувач вводить пароль rawPassword;
2) сервер бере rawPassword і пропускає через функцію;
3) порівнює результат із тим, що зберігається;
4) ухвалює рішення true/false.
Щоб відчути різницю на практиці, корисно побачити навіть найпростіший «звичайний» хеш, наприклад SHA-256. Важливо: я зараз не пропоную використовувати SHA-256 для паролів, ми трохи згодом пояснимо, чому це погана ідея. Я пропоную подивитися на принцип: ми не можемо розшифрувати, але можемо порівняти.
Мініприклад на Java:
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
// Отримуємо реалізацію SHA-256 (це швидкий криптографічний хеш, але НЕ парольний)
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
// Хешуємо рядок "як є": однаковий ввід -> однаковий результат
byte[] hash = sha256.digest("qwerty123".getBytes(StandardCharsets.UTF_8));
// Перетворюємо байти на hex-рядок, щоб зручно зберігати й друкувати
String stored = HexFormat.of().formatHex(hash);
System.out.println(stored); // приклад: ef92b7... (фіксований рядок)
Якщо ви запустите цей код двічі з одним і тим самим входом, ви отримаєте той самий результат. Це важлива властивість: однаковий ввід → однаковий хеш. Для багатьох завдань це зручно. Але для паролів є нюанс, і він неприємний: якщо два користувачі обрали однаковий пароль, то й хеш буде однаковий. Отже, витік бази дасть змогу побачити «групи однакових паролів». А ще можна заздалегідь підготувати таблиці популярних паролів та їх SHA-256 хешів, і перевірка стане дуже дешевою.
Тому для паролів у правильній схемі майже завжди зʼявляється поняття salt — випадкової домішки до пароля перед хешуванням. Сіль робить так, що один і той самий пароль у двох різних користувачів перетворюється на два різні збережені значення.
Сіль на рівні ідеї виглядає так:
stored = hash( salt + rawPassword )
І вже цей крок ламає багато готових таблиць атакувальника. Але й цього все одно недостатньо — і ось ми дісталися найважливішої частини лекції.
4. Швидкий хеш і brute force як економіка
Наївне очікування новачка зазвичай таке: SHA-256 же криптографічний, отже безпечно. І ось тут нас підстерігає несподіванка: для паролів безпечно не те, що «криптографічно гарно», а те, що економічно боляче атакувати.
Давайте спростимо. Якщо зловмисник украв вашу базу з хешами паролів, далі починається офлайн-атака. Це означає: він не лізе на ваш сервер, не натрапляє на rate limit і не отримує блокування облікового запису. Він спокійно сидить у себе й перебирає варіанти. І ось тут головний параметр — швидкість перевірки одного пароля.
Якщо перевірка швидка — мільйони або десятки мільйонів спроб за секунду на відеокарті, — то «складні» паролі користувачів раптом стають не такими вже складними. Особливо якщо користувач обрав щось із топа популярних паролів. А люди так люблять робити — це стабільна традиція людства, як «не читати ліцензійну угоду».
Щоб відчути, наскільки «швидко» може бути швидко, можна зробити мінізамір SHA-256 у циклі. Він не науковий, але чудово ламає ілюзію.
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
// Засікаємо час, щоб побачити: звичайні хеші обчислюються дуже швидко
long start = System.nanoTime();
for (int i = 0; i < 200_000; i++) {
// Важливо: digest() тут — це "вартість однієї спроби" для атакувальника
sha256.digest("qwerty123".getBytes(StandardCharsets.UTF_8));
}
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("Час SHA-256 = " + ms + " мс"); // приклад: Час SHA-256 = 40 мс
Число у вас буде іншим, залежно від заліза, але ідея залишиться: звичайні хеші дуже швидкі. І якщо захисник радіє «як швидко у нас працює вхід», атакувальник радіє ще сильніше: «як швидко у мене працює перебір».
Звідси народжується головний парадокс парольного хешування:
Для паролів ми спеціально хочемо повільну перевірку.
Не настільки повільну, щоб користувач встиг постаріти між введенням пароля і входом у систему, але достатньо повільну, щоб атака перебором стала дорогою. І це приводить нас до спеціального класу функцій: adaptive one-way functions.
5. Adaptive one-way functions: налаштовувана вартість
Слово adaptive тут не про «адаптивний дизайн» і не про «адаптацію до користувача» — хоча було б кумедно. Воно про інше: функція має дозволяти налаштовувати вартість перевірки. Сьогодні у вас один рівень потужності заліза, через 5 років — інший. Отже, у вас має бути ручка, яку можна підкрутити: зробити хешування й перевірку дорожчими.
Якщо висловитися зовсім просто, адаптивна функція — це така функція, у якої є параметри на кшталт:
- скільки разів повторити обчислення;
- скільки памʼяті використати;
- наскільки «важкою» зробити операцію.
І далі є два важливі ефекти.
Перший ефект: користувач майже не помічає різниці, тому що вхід відбувається рідко, а одна перевірка пароля на сервері зазвичай займає частки секунди або десятки мілісекунд. Це нормально.
Другий ефект: атакувальник, який хоче перевіряти мільйони паролів, раптом платить цю ціну мільйони разів. І це вже стає боляче, дорого й повільно.
Можна навіть намалювати це як побутову аналогію. Уявіть, що перевірка пароля — це пройти через турнікет. Звичайний швидкий хеш — це турнікет, який проходять за 0.001 секунди. Адаптивний парольний хеш — це турнікет, який проходять за 0.1 секунди. Користувач проходить разок і не страждає. А ось зловмисник, який хоче прогнати через турнікет 100 мільйонів спроб, раптом розуміє, що він підписався на дуже довгий марафон.
При цьому важлива частина моделі: функція має бути one-way — необоротною — і зазвичай включає salt, щоб однакові паролі не давали однакових хешів. Тобто це не просто «давайте поспимо 100 мс», а криптографічний алгоритм, який свідомо робить перевірку дорогою.
І ось тут зʼявляється набір назв, які вам потрібно знати на рівні бекенд-розробника: bcrypt, PBKDF2, scrypt, argon2.
6. bcrypt, PBKDF2, scrypt, argon2: що памʼятати
Дуже хочеться зробити вигляд, що один алгоритм «найкращий назавжди», але реальність трохи складніша: усі ці варіанти розв’язують одне завдання — роблять перевірку пароля дорогою й стійкою до перебору, — просто роблять це різними способами і з різними параметрами. На рівні Junior-розробника нам важливо не сперечатися, «хто крутіший», а розуміти: у парольного хешування має бути вартість, має бути сіль, і в алгоритму мають бути параметри, які можна посилювати.
Зведімо в таблицю, що це таке й яку «ручку» ви крутите:
| Алгоритм | Що це по суті | Головний параметр «дороговизни» | Нюанс, який корисно памʼятати |
|---|---|---|---|
| bcrypt | класика для паролів, повільний хеш | cost/strength | зазвичай зручний як стандартний базовий рівень |
| PBKDF2 | багаторазове повторення HMAC | iterations | часто трапляється у стандартах і корпоративних системах |
| scrypt | робить атаку дорогою ще й по памʼяті | N/r/p (memory cost) | намагається ускладнити перебір на GPU/ASIC |
| argon2 | сучасний переможець конкурсів із хешування паролів | memory + iterations + parallelism | вважається дуже сильним варіантом, є різні режими |
Важливо помітити спільний принцип: результат зберігається не як «просто хеш», а зазвичай як рядок, у якому вшиті і параметри, і сіль. Тобто збережене значення — це не просто «цифри», а «упакована» інформація для перевірки.
Найзрозуміліший для демонстрації новачку — bcrypt, тому що він широко використовується й дає гарний «вау-ефект»: один і той самий пароль щоразу перетворюється на різні рядки, але все одно коректно перевіряється.
Покажімо це на мінімальному прикладі:
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
// BCrypt сам генерує сіль і упаковує параметри в підсумковий рядок
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
// Один і той самий пароль -> щоразу новий результат (через сіль)
String first = encoder.encode("qwerty123");
String second = encoder.encode("qwerty123");
System.out.println(first.equals(second)); // false
// Перевірка виконується через matches(raw, stored), а не через equals()
System.out.println(encoder.matches("qwerty123", first)); // true
Тут відбуваються одразу два важливі психологічні злами.
Перший: ви перестаєте очікувати, що «один пароль завжди перетворюється на один і той самий хеш». Для паролів це якраз небажано.
Другий: ви перестаєте перевіряти пароль через «порівняю два рядки». Правильна перевірка — це matches(raw, stored) (як саме це оформлюється у Spring Security — буде наступна лекція, але сенс ви вже бачите).
Якщо хочеться побачити, що це справді різні рядки, можна вивести їх у консоль. Тільки не перетворюйте це на звичку «логувати паролі», навіть якщо це хеш.
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
// Отримуємо рядок, усередині якого є і сіль, і параметри (work factor)
String hash = encoder.encode("qwerty123");
// У реальному сервісі такі речі краще не логувати: це приваблива ціль для витоків
System.out.println(hash); // приклад: $2a$10$w1m3Q... (кожного разу різний)
Рядок буде щоразу різний, і це нормально: всередині є сіль і параметри. Під час перевірки алгоритм «розуміє», які параметри використано, і коректно порівнює.
7. Мінісхема перевірки пароля
Поки що у нас може бути відчуття, що ми говоримо «у вакуумі» про криптографію. Але насправді це дуже прикладна частина бекенд-механіки: коли система перевіряє пароль, вона бере те, що ввів користувач, і зіставляє з тим, що зберігається. І вся суть у тому, що зберігається не пароль, а значення для перевірки.
На рівні простої схеми, без деталей фреймворка, це виглядає так:
flowchart TD
%% Користувач вводить "сирий" пароль (rawPassword)
A["rawPassword (ввід користувача)"] --> C["verify(rawPassword, storedValue)"]
%% У базі лежить не пароль, а "упаковане" значення для перевірки (хеш + сіль + параметри)
B["storedValue (те, що зберігається в системі)"] --> C
%% Результат перевірки: пускати чи ні
C --> D["true / false"]
І тепер стає зрозуміло, чому ми так наголошуємо на “adaptive” і “дороговизни”. Тому що verify(...) — це саме та точка, яку атакувальник повторюватиме безліч разів в офлайн-переборі. Отже, саме тут ми хочемо, щоб алгоритм був не «швидким і зручним», а «спеціально дорогим, але все ще прийнятним для легального користувача».
Якщо вам хочеться зовсім коротко повʼязати це з тим, про що ми вже говорили раніше на рівні внутрішньої роботи Spring Security, то ідея така: механізм автентифікації, який ухвалює рішення «пускати / не пускати», не повинен «діставати пароль зі сховища». Він має викликати перевірку на кшталт «чи збігається ввід із збереженим значенням». Саме тому зберігання паролів — окрема дисципліна, а не рядкове поле «про всяк випадок».
8. Типові помилки під час зберігання паролів
Помилка № 1: використовувати шифрування замість хешування.
Шифрування передбачає наявність ключа, який можна викрасти. Якщо ключ скомпрометовано — усі паролі стають читабельними. Для паролів це неприйнятно: їх не потрібно вміти «розшифровувати», їх потрібно лише перевіряти.
Помилка № 2: очікувати однаковий результат для однакових паролів.
Інтуїтивно здається, що один пароль → один і той самий рядок. Але для безпечного зберігання це якраз неправильно. Сучасні алгоритми використовують сіль, тому однакові паролі дають різні хеші. Перевірка відбувається через порівняння «чи підходить пароль», а не через equals().
Помилка № 3: використовувати швидкі хеш-алгоритми (MD5, SHA-256).
Ці алгоритми занадто швидкі для паролів. Це робить перебір дешевим: атакувальник може перевіряти мільйони варіантів за секунду. Для паролів потрібні «повільні» алгоритми (bcrypt, Argon2), які спеціально сповільнюють перебір.
Помилка № 4: вигадувати власну криптографічну схему.
Ідея «зробимо пару SHA-256 із сіллю» звучить розумно, але на практиці призводить до слабких або нестабільних рішень. Криптографія — це сфера, де саморобні рішення майже завжди програють стандартним алгоритмам.
Помилка № 5: ігнорувати потребу в адаптивності алгоритму.
Безпека паролів має зростати разом з обчислювальними можливостями. Якщо алгоритм не дає налаштовувати «вартість» (work factor), з часом він застаріває. Спеціалізовані рішення дозволяють збільшувати складність без зміни всієї системи зберігання.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ