Хеширование паролей: bcrypt/argon2 и соль

Пароли нельзя хранить — их можно только хешировать, и то правильным алгоритмом.

Хеш пароля — необратимое преобразование пароля, по которому нельзя восстановить исходник, но можно проверить введённый пароль. Соль — случайная добавка, уникальная для каждого пароля.

Почему нельзя хранить пароли в открытом виде

Любая БД может утечь. Если пароли лежат как есть, утечка отдаёт сразу все аккаунты — и, поскольку люди переиспользуют пароли, аккаунты на других сайтах тоже. Поэтому в БД хранят не пароль, а его необратимый отпечаток; при входе сравнивают отпечатки.

Стоит подчеркнуть, что угроза не сводится к одному лишь взлому базы через интернет. Дамп БД может утечь десятком путей: через ошибочно открытый бэкап в облачном хранилище, через лог, в который случайно попало тело запроса с паролем, через инсайдера с доступом к продакшен-базе, через украденный ноутбук с локальной копией данных. Хранение пароля в открытом виде превращает любой из этих инцидентов в немедленную катастрофу для всех пользователей сразу. Хеширование же меняет правила игры: даже заполучив всю таблицу, атакующий получает не пароли, а отпечатки, из которых исходники не вытащить напрямую.

Есть и этический аргумент: сам факт того, что вы знаете пароль пользователя в открытом виде, уже плох — пользователь доверяет вам секрет, который, скорее всего, использует и на других сайтах. Признак неблагополучия, который видит даже не-специалист: если сервис при восстановлении присылает вам ваш старый пароль в письме, значит, он хранил его в воспроизводимом виде. Правильная система не может «вспомнить» ваш пароль — она умеет лишь проверить введённый и предложить задать новый.

Почему MD5 и SHA-256 не годятся

Эти алгоритмы созданы быть быстрыми — и это их недостаток для паролей. Современная видеокарта считает миллиарды MD5-хешей в секунду, перебирая пароли по словарю практически мгновенно. Для паролей нужен намеренно медленный алгоритм, чтобы перебор стал непрактичным.

// Уязвимо: быстрый хеш без соли
hash = md5(password)            // подбирается перебором мгновенно;
                                // одинаковые пароли -> одинаковые хеши

// Безопасно: специализированный медленный алгоритм с солью
hash = argon2(password)         // соль и параметры стоимости — внутри

Зачем нужна соль

Без соли одинаковые пароли дают одинаковые хеши: видно, у кого пароль совпадает, и можно заранее посчитать таблицу хешей популярных паролей (rainbow table) один раз на всех. Соль — уникальная случайная строка для каждого пароля — делает хеши разными даже у одинаковых паролей и обесценивает предрасчёт: атаковать придётся каждый аккаунт отдельно.

Поясним экономику атаки, ведь именно она объясняет, зачем соль обязана быть уникальной. Без соли злоумышленник один раз вкладывается в построение огромной таблицы «популярный пароль → его хеш» и затем мгновенно сопоставляет с ней утёкшую базу любого размера: миллион пользователей с одинаковым «123456» вскрываются разом, одной строкой таблицы. Уникальная соль ломает эту экономику в корне — предрасчитанная таблица становится бесполезной, потому что хеш каждого пользователя солён по-своему. Атакующему приходится перебирать кандидатов заново для каждого аккаунта в отдельности, и стоимость взлома растёт пропорционально числу пользователей, а не остаётся фиксированной.

Важная деталь: соль не обязана быть секретной. Её спокойно хранят рядом с хешем (а специализированные алгоритмы и вовсе кладут внутрь итоговой строки) — её ценность не в скрытности, а в уникальности. Иногда поверх соли добавляют ещё и pepper — общий секрет, который в отличие от соли хранится отдельно от базы (например, в секрет-менеджере или переменной окружения). Тогда даже при утечке только базы, без секрета приложения, перебор затрудняется ещё сильнее; но pepper — это дополнительный слой, а не замена уникальной соли.

без соли:  "123456" -> e10adc...   (у всех с этим паролем одинаково)
с солью:   "123456"+saltA -> 7f3b..  ;  "123456"+saltB -> 91ac..
           одинаковый пароль -> разные хеши -> rainbow-таблицы бесполезны

Правильный выбор: bcrypt, scrypt, argon2

Эти алгоритмы спроектированы для паролей: они медленные, с настраиваемой стоимостью (work factor), а argon2 и scrypt ещё и memory-hard — требуют много памяти, что мешает массовому перебору на GPU. Соль они генерируют и хранят внутри итогового хеша — отдельно её вести не нужно.

// Концептуально (псевдокод библиотеки)
hash = passwordHasher.hash(password)       // соль + стоимость внутри строки хеша
...
ok = passwordHasher.verify(input, hash)    // безопасное сравнение

// Пример итоговой строки argon2 (соль и параметры закодированы внутри):
// $argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$hashbase64...

Как работает под капотом: work factor против железа

Параметр стоимости задаёт число итераций (и памяти). Раз в пару лет железо ускоряется — стоимость повышают, чтобы проверка одного пароля занимала, скажем, ~250 мс: для пользователя незаметно, для перебора миллионов вариантов — катастрофа. Поскольку параметры хранятся в строке хеша, можно плавно мигрировать: при следующем входе перехешировать старые пароли с новыми параметрами.

Отдельного внимания заслуживает свойство memory-hard у argon2 и scrypt. Дело в том, что атакующий перебирает пароли не на обычном процессоре, а на массивах видеокарт или специальных чипах, где тысячи ядер считают хеши параллельно. Алгоритмы, которым нужно много вычислений, такому железу всё равно поддаются — ядер просто очень много. Но если алгоритм требует ещё и заметного объёма памяти на каждое вычисление, параллелизм упирается в дефицит памяти: видеокарта не может одновременно гонять тысячи копий, каждой из которых нужны мегабайты. Именно поэтому современная рекомендация для новых систем — argon2id: он сочетает настраиваемую вычислительную стоимость с требованием к памяти и тем самым уравнивает шансы защитника и хорошо оснащённого атакующего.

Практический вывод о миграции: переход на сильный алгоритм не требует знать старые пароли. Поскольку проверка хеша происходит при входе, вы можете при успешном логине прозрачно пересчитать пароль уже новым алгоритмом или с повышенной стоимостью и сохранить обновлённый хеш. Со временем активные пользователи мигрируют сами, без рассылок и принудительных сбросов. А заодно это повод заложить в код условие «если параметры хеша устарели — перехешировать», чтобы система оставалась актуальной без ручных вмешательств.

Частые ошибки

  • MD5/SHA без соли. Быстро и предсказуемо — мечта для перебора.
  • Общая соль на всех. Соль обязана быть уникальной на пароль.
  • Своя «крипта». Берите проверенную библиотеку, не изобретайте схему.
  • Обычное сравнение строк хешей. Используйте функцию сравнения, устойчивую к таймингу.
  • Возврат старого пароля при восстановлении. Раз система может его «вспомнить», значит, хранит обратимо — это сразу красный флаг.
  • Двойное хеширование «для надёжности». sha256(md5(p)) не делает быстрый алгоритм медленным; нужен именно специализированный.

Итоги

  • Пароли не хранят, а хешируют необратимо; в БД — только хеш.
  • MD5/SHA слишком быстры; для паролей нужны bcrypt/scrypt/argon2 с настраиваемой стоимостью.
  • Уникальная соль на каждый пароль обесценивает rainbow-таблицы.
  • Повышайте work factor со временем; используйте готовые библиотеки.
Проверьте себя
1. Почему MD5 и SHA-256 не подходят для хранения паролей?
AОни необратимы
BОни слишком быстрые, поэтому пароли перебираются миллиардами в секунду; нужен намеренно медленный алгоритм
CОни не поддерживают юникод
DОни дают слишком длинный хеш
2. Зачем к паролю добавляют уникальную соль?
AЧтобы пароль стало легче запомнить
BЧтобы одинаковые пароли давали разные хеши и предрасчитанные rainbow-таблицы стали бесполезны
CЧтобы ускорить хеширование
DЧтобы можно было восстановить пароль
3. Что задаёт параметр стоимости (work factor) в bcrypt/argon2?
AДлину пароля
BВычислительную (и для argon2 — память) трудоёмкость хеширования, которую повышают по мере роста производительности железа
CСрок действия пароля
DЧисло разрешённых попыток входа