1. «Зашифруем пароль» — неверная цель
Мы уже зафиксировали главный принцип: серверу не нужен пароль “обратно”, ему нужна только безопасная проверка совпадения. Теперь важно развести две модели, которые новичок чаще всего смешивает: обратимое шифрование и необратимую password verification.
Когда вы только начинаете, очень легко сказать: «Пароль — секрет. Значит, его нужно зашифровать». Звучит логично: секреты же шифруют. Но в паролях проблема другая: нам не нужно получать пароль обратно, нам нужно проверять совпадение. Это тонкая разница, которая полностью меняет технику хранения.
Давайте на секунду представим, что вы — сервер. К вам приходит пользователь и говорит: «Вот мой пароль». Вам нужно ответить: «Да, это тот самый пароль» или «Нет, не тот». И вот ключевой момент: серверу не нужно знать исходный пароль в явном виде ни сейчас, ни через неделю, ни через год. Серверу нужно уметь сделать проверку так, чтобы даже при утечке хранилища злоумышленник не получил «список паролей всех пользователей», а получил «набор значений, которые очень трудно превратить обратно в пароли».
Из этого вытекает важная формулировка цели:
Цель хранения пароля — безопасная проверка совпадения, а не восстановление исходной строки.
Чтобы закрепить, сравним два подхода в виде маленькой таблицы.
| Вопрос | Encryption (шифрование) | Hashing (хеширование для паролей) |
|---|---|---|
| Можно ли получить исходный пароль обратно? | Да (если есть ключ) | Нет (и это хорошо) |
| Что хранится на сервере? | Зашифрованный пароль + (где-то) ключ | Хеш + параметры (и обычно соль) |
| Что будет при утечке базы? | Если утечёт ключ — пароли «раскрыты сразу» | Пароли не раскрываются напрямую, нужен перебор |
| Какая правильная цель? | Скрыть содержимое, но сохранить обратимость | Убить обратимость и оставить только проверку |
Здесь нет «шифрование всегда плохо». Шифрование — отличный инструмент, просто не для паролей. А дальше нам понадобится ещё один слой: даже «просто хеширование» не всегда достаточно, и для паролей нужен специальный класс функций — adaptive one-way functions.
2. Encryption: обратимость и ключ
Шифрование — это технология, где подразумевается обратимость. Если у вас есть ключ, вы можете сделать encrypt(plainText) и получить «нечитаемую кашу», а потом сделать decrypt(cipherText) и вернуть исходный текст. В этом нет ничего плохого — это буквально смысл шифрования. Но у пароля нам не нужна операция decrypt, и вот почему это важно именно как backend‑разработчику.
Представим, что вы всё-таки решили хранить пароль «в зашифрованном виде». Тогда где-то в системе должен быть ключ. И этот ключ должен быть доступен приложению, иначе оно не сможет расшифровать пароль и сравнить его с тем, что ввёл пользователь. То есть у вас появляется «супер‑секрет», который открывает все пароли сразу. И в реальном мире этот секрет либо окажется рядом (в конфиге, в переменной окружения, в секрет‑хранилище), либо его украдут другим способом. А дальше — неприятная математика: один ключ → доступ ко всем паролям.
Для демонстрации именно ментальной модели (без настоящей криптографии и без опасных «учебных AES‑примеров», которые потом кто-то копирует в прод) можно показать идею через простой интерфейс:
public interface Encryptor {
// Шифруем исходный текст в "нечитаемый" вид (обратимо при наличии ключа)
String encrypt(String plainText);
// Расшифровываем обратно (для паролей это как раз лишняя/опасная операция)
String decrypt(String cipherText);
}
Если вы «шифруете пароль», вы неизбежно приходите к мысли: «нам нужен decrypt». А теперь честно: зачем серверу уметь доставать пароль обратно? Чтобы… что? Чтобы случайно вывести его в лог? Чтобы «помочь пользователю вспомнить»? (Спойлер: так делать нельзя.) Чтобы переслать в поддержку? (Ещё хуже.)
У паролей другая правильная модель: сервер хранит не то, что можно расшифровать, а то, что можно только проверить. Поэтому вместо схемы «encrypt/decrypt» нам нужна схема «hash + verify».
Наглядно это можно изобразить вот так:
flowchart LR
%% Идея: шифрование предполагает обратимость (есть decrypt)
subgraph ENC["ENCRYPTION"]
P1["password"] -->|encrypt| C["ciphertext"]
C -->|decrypt| P2["password"]
end
flowchart LR
%% Идея: при хешировании для паролей мы ничего "не возвращаем назад", только проверяем совпадение
subgraph HASH["HASHING"]
P3["password"] -->|hash| S["stored_value"]
P4["password"] --> V["verify"]
S --> V
V --> R["true/false"]
end
И именно вторая строка — то, что нам нужно.
3. Hashing: проверяем совпадение, не пытаясь «узнать пароль»
Хеширование в самом базовом смысле — это преобразование данных в фиксированное значение (обычно строку/байты), которое удобно сравнивать и хранить. Важно: хеш не предназначен для восстановления исходного значения. В идеальном мире вы не можете взять хеш и «раскрутить» его обратно в пароль. Вы можете только пробовать догадаться: «а если пароль был вот таким, совпадёт ли хеш?».
И вот здесь появляется правильная модель проверки:
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 time = " + ms + " ms"); // пример: SHA-256 time = 40 ms
Число у вас будет другим (зависит от железа), но идея останется: обычные хеши очень быстрые. И если защитник радуется «как быстро у нас логин работает», атакующий радуется ещё сильнее: «как быстро у меня перебор работает».
Отсюда рождается главный парадокс парольного хеширования:
Для паролей мы специально хотим медленную проверку.
Не «настолько медленную, что пользователь успеет состариться между вводом пароля и входом в систему», но достаточно медленную, чтобы атака перебором стала дорогой. И это приводит нас к специальному классу функций: adaptive one-way functions.
5. Adaptive one-way functions: настраиваемая стоимость
Слово adaptive здесь не про «адаптивный дизайн» и не про «адаптацию к пользователю» (хотя было бы забавно). Оно про другое: функция должна позволять настраивать стоимость проверки. Сегодня у вас один уровень мощности железа, через 5 лет — другой. Значит, у вас должна быть ручка, которую можно подкрутить: сделать хеширование (и проверку) дороже.
Если выразить это совсем по‑простому, адаптивная функция — это такая функция, у которой есть параметры вроде:
- сколько раз повторить вычисление;
- сколько памяти использовать;
- насколько «тяжёлой» сделать операцию.
И дальше есть два важных эффекта.
Первый эффект: пользователь не замечает большой разницы, потому что логин происходит редко и одна проверка пароля на сервере (условно) занимает доли секунды или десятки миллисекунд. Это нормально.
Второй эффект: атакующий, который хочет проверять миллионы паролей, внезапно платит эту цену миллионы раз. И вот это уже становится больно, дорого и медленно.
Можно даже нарисовать это как бытовую аналогию. Представьте, что проверка пароля — это пройти через турникет. Обычный быстрый хеш — это турникет, который проходит за 0.001 секунды. Адаптивный парольный хеш — это турникет, который проходит за 0.1 секунды. Пользователь проходит разок и не страдает. А вот злоумышленник, который хочет прогнать через турникет 100 миллионов попыток, внезапно понимает, что он подписался на очень длинный марафон.
При этом важная часть модели: функция должна быть one-way (необратимой) и обычно включает salt (чтобы одинаковые пароли не давали одинаковых хешей). То есть это не просто «давайте поспим 100 мс», это криптографический алгоритм, который осознанно делает проверку дорогой.
И вот здесь появляется набор имён, которые вам нужно знать на уровне backend‑разработчика: bcrypt, PBKDF2, scrypt, argon2.
6. bcrypt, PBKDF2, scrypt, argon2: что помнить
Очень хочется сделать вид, что один алгоритм «самый лучший навсегда», но реальность чуть сложнее: все эти варианты решают одну задачу (сделать проверку пароля дорогой и устойчивой к перебору), просто делают это разными способами и с разными параметрами. На уровне Junior‑разработчика нам важно не спорить «кто круче», а понимать: у парольного хеширования должна быть стоимость, должна быть соль, и у алгоритма должны быть параметры, которые можно усиливать.
Сведём в таблицу «что это и какую ручку вы крутите»:
| Алгоритм | Что это по сути | Главный параметр «дороговизны» | Нюанс, который полезно помнить |
|---|---|---|---|
| bcrypt | классика для паролей, медленный хеш | cost/strength | обычно удобен как стандартный baseline |
| PBKDF2 | многократное повторение HMAC | iterations | часто встречается в стандартах и корпоративных системах |
| scrypt | делает атаку дорогой ещё и по памяти | N/r/p (memory cost) | пытается усложнить GPU/ASIC перебор |
| argon2 | современный победитель конкурсов по password hashing | 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. Мини‑схема проверки пароля
Пока у нас может быть ощущение, что мы говорим «в вакууме» про криптографию. Но на самом деле это очень прикладная часть backend‑механики: когда система проверяет пароль, она берёт то, что ввёл пользователь, и сопоставляет с тем, что хранится. И вся суть в том, что хранится не пароль, а значение для проверки.
На уровне простой схемы (без деталей фреймворка) это выглядит так:
flowchart TD
%% Пользователь вводит "сырой" пароль (rawPassword)
A["rawPassword (ввод пользователя)"] --> C["verify(rawPassword, storedValue)"]
%% В базе лежит не пароль, а "упакованное" значение для проверки (хеш+соль+параметры)
B["storedValue (то, что хранится в системе)"] --> C
%% Результат проверки: пускать или нет
C --> D["true / false"]
И теперь становится ясно, почему мы так упираем на “adaptive” и “дороговизну”. Потому что verify(...) — это самая точка, которую атакующий будет повторять бесконечно много раз в офлайн‑переборе. Значит, именно здесь мы хотим, чтобы алгоритм был не «быстрым и удобным», а «специально дорогим, но всё ещё приемлемым для легального пользователя».
Если вам хочется совсем коротко связать это с тем, что мы уже обсуждали раньше на уровне Spring Security internals, то идея такая: механизм аутентификации (который принимает решение «пускать / не пускать») не должен “доставать пароль из хранилища”, он должен вызвать проверку вида “совпадает ли ввод с сохранённым значением”. Это и есть причина, почему password storage — отдельная дисциплина, а не строковое поле “на память”.
8. Типичные ошибки при хранении паролей
Ошибка №1: использовать шифрование вместо хеширования.
Шифрование предполагает наличие ключа, который можно украсть. Если ключ скомпрометирован — все пароли становятся читаемыми. Для паролей это недопустимо: их не нужно уметь “расшифровывать”, их нужно только проверять.
Ошибка №2: ожидать одинаковый результат для одинаковых паролей.
Интуитивно кажется, что один пароль → одна строка. Но для безопасного хранения это как раз неправильно. Современные алгоритмы используют соль, поэтому одинаковые пароли дают разные хеши. Проверка происходит через сравнение “подходит ли пароль”, а не через equals().
Ошибка №3: использовать быстрые хеш-алгоритмы (MD5, SHA-256).
Эти алгоритмы слишком быстрые для паролей. Это делает перебор дешёвым: атакующий может проверять миллионы вариантов в секунду. Для паролей нужны “медленные” алгоритмы (bcrypt, Argon2), которые специально замедляют перебор.
Ошибка №4: придумывать свою криптографическую схему.
Идея “сделаем пару SHA-256 с солью” звучит разумно, но на практике приводит к слабым или нестабильным решениям. Криптография — это область, где самодельные решения почти всегда проигрывают стандартным алгоритмам.
Ошибка №5: игнорировать необходимость адаптивности алгоритма.
Безопасность паролей должна расти вместе с вычислительными возможностями. Если алгоритм не позволяет настраивать “стоимость” (work factor), со временем он устаревает. Специализированные решения позволяют увеличивать сложность без смены всей системы хранения.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ