Хеширование паролей: 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 со временем; используйте готовые библиотеки.