JavaRush /Курси /Spring Security /Хешування vs

Хешування vs шифрування і адаптивні односторонні функції

Spring Security
Рівень 6 , Лекція 1
Відкрита

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), з часом він застаріває. Спеціалізовані рішення дозволяють збільшувати складність без зміни всієї системи зберігання.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ