Hashing vs encryption и adaptive one-way

Spring Security
6 уровень , 1 лекция
Открыта

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), со временем он устаревает. Специализированные решения позволяют увеличивать сложность без смены всей системы хранения.

1
Задача
Spring Security, 6 уровень, 1 лекция
Недоступна
Один пароль, две разные BCrypt-строки
Один пароль, две разные BCrypt-строки
1
Задача
Spring Security, 6 уровень, 1 лекция
Недоступна
Сравнение SHA-256 и BCrypt на одном raw password
Сравнение SHA-256 и BCrypt на одном raw password
Комментарии (1)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Anonymous #3534203 Уровень 20
13 июня 2026
Ошибка в первом Сейчас файлы находятся в ru/rush/... вместо ожидаемого ru/javarush/....