Правильное хранение паролей

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

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

Базы данных утекают регулярно: ошибка в коде, SQL-инъекция, украденный бэкап, доступ инсайдера. Вопрос не «утечёт ли когда-нибудь таблица пользователей», а «что злоумышленник из неё достанет». Если пароли лежат в открытом виде, утечка мгновенно становится катастрофой не только для вашего сервиса, но и для всех мест, где люди использовали тот же пароль. Поэтому правильное хранение паролей — это про ограничение ущерба: база утекла, а пароли остались бесполезным набором хешей. Несанкционированный доступ к чужой базе — это статья 272 УК РФ; мы разбираем защиту, а не способ добыть чужие данные.

Зачем это знать защитнику

Категория Identification and Authentication Failures прочно держится в OWASP Top 10, и слабое хранение паролей — её классическая часть. Разработчик, который понимает, чем хеш отличается от шифрования и почему MD5 непригоден, не повторит ошибку, которую делали сотни компаний до утечек. Специалист Blue Team по этим же признакам оценивает зрелость чужого кода: увидел MD5(password) в ревью — нашёл критическую проблему.

Чего делать нельзя

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

Быстрые хеши (MD5, SHA-1, SHA-256) без защиты. Это криптографические хеши, но они спроектированы быть быстрыми — а для паролей это недостаток. Современная видеокарта перебирает миллиарды кандидатов SHA-256 в секунду, поэтому короткие и популярные пароли подбираются почти мгновенно. Уязвимый код выглядит обманчиво «солидно»:

# УЯЗВИМО: быстрый хеш без соли, мгновенно ломается перебором
import hashlib
password = "hunter2"
stored = hashlib.sha256(password.encode()).hexdigest()
print(stored)

Вывод:

f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7

Один и тот же пароль всегда даёт один и тот же хеш — отсюда и проблема. Беда не только в скорости. Без соли одинаковые пароли дают одинаковый хеш, поэтому из утечки сразу видно, у кого пароли совпадают, и работают заранее посчитанные таблицы (rainbow tables). Эти таблицы — словарь «популярный пароль → его хеш», и без соли они применимы к вашей базе как есть.

Как делать правильно: соль, медленный хеш, перец

Соль (salt)

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

Медленный (адаптивный) хеш

Для паролей нужны специальные функции, которые намеренно медленные и умеют становиться ещё медленнее со временем: bcrypt, scrypt, Argon2. У них есть фактор стоимости (cost / work factor): чем он выше, тем больше вычислений на одну проверку. Для легального входа лишние ~200 мс незаметны, а для перебора миллиардов кандидатов это превращает задачу из «минуты» в «столетия». Argon2 и scrypt дополнительно требуют много памяти, что обесценивает дешёвый перебор на видеокартах.

# БЕЗОПАСНО (псевдокод): соль внутри, стоимость настраивается
# bcrypt сам генерирует случайную соль и кладёт её в строку хеша:
hash = bcrypt(password, cost=12)
# при логине:
ok = bcrypt_verify(input_password, stored_hash)  # сравнение в постоянном времени

Важная деталь: bcrypt/Argon2 встраивают соль и параметры прямо в строку хеша. Поэтому отдельную колонку для соли заводить не нужно — алгоритм при проверке достанет её из сохранённого значения. Никогда не пишите хеш-функцию для паролей сами: используйте проверенную библиотеку (например, bcrypt, argon2-cffi, или passlib в Python).

Перец (pepper)

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

Как это работает под капотом

При регистрации приложение берёт пароль, генерирует случайную соль, прогоняет «пароль + соль» через адаптивный хеш с заданной стоимостью и сохраняет получившуюся строку (в ней уже зашиты алгоритм, стоимость и соль). При входе оно достаёт эту строку, извлекает из неё параметры и соль, хеширует введённый пароль теми же параметрами и сравнивает результаты. Само сравнение делают в постоянном по времени режиме (constant-time), чтобы по длительности ответа нельзя было угадывать хеш по символам. Восстановить исходный пароль из хеша нельзя в принципе — функция односторонняя; можно лишь проверить кандидата, а адаптивная стоимость делает массовую проверку кандидатов невыгодной.

Как защититься

  • Только адаптивный хеш с солью. Argon2id (предпочтительно), scrypt или bcrypt с разумным cost-фактором. Никогда не plaintext, не обратимое шифрование и не «голый» MD5/SHA.
  • Доверьте детали библиотеке. Генерацию соли, формат хранения и constant-time сравнение делает проверенная реализация. Свой велосипед почти гарантированно содержит ошибку.
  • Калибруйте стоимость под своё железо и повышайте её со временем: вход должен занимать заметную, но приемлемую долю секунды. Предусмотрите бесшовное обновление: при успешном входе можно перехешировать пароль с новыми параметрами.
  • Защита от перебора на входе. Хеширование защищает утёкшую базу; живую форму логина защищают отдельно: ограничение числа попыток (rate limiting) и временная блокировка по аккаунту/IP, экспоненциальные задержки, CAPTCHA при подозрительной активности. Это отбивает онлайн-подбор и credential stuffing.
  • Поддержка длинных парольных фраз и проверка по утёкшим спискам. Разрешайте длинные пароли и не режьте спецсимволы; отклоняйте пароли из публичных утечек. Многофакторная аутентификация (MFA) резко снижает ценность украденного пароля.
  • Обнаружение. Всплеск неудачных входов, попытки по многим аккаунтам с одного источника, аномальная география — сигналы атаки на пароли. Логируйте и алертите.

Юридическое напоминание: проверять стойкость хранения паролей можно только в своей системе или в рамках легального пентеста с письменным разрешением. Брутфорс чужих учёток наказуем (ст. 272 УК РФ).

Итоги

  • Цель правильного хранения — чтобы утечка базы не выдала пароли; хеш необратим, в отличие от шифрования.
  • Plaintext, обратимое шифрование и быстрые хеши (MD5/SHA) без защиты — недопустимы: они ломаются перебором.
  • Соль делает каждый хеш уникальным и убивает rainbow-таблицы; адаптивный хеш (Argon2/scrypt/bcrypt) делает перебор невыгодным; перец добавляет секрет вне базы.
  • Не пишите хеширование сами — берите проверенную библиотеку; калибруйте и повышайте стоимость со временем.
  • Форму логина защищают отдельно: rate limiting, блокировки, MFA и мониторинг неудачных попыток.
Проверьте себя
1. Почему «голый» SHA-256 без соли — плохой выбор для хранения паролей?
ASHA-256 обратим, и из хеша легко получить пароль
BSHA-256 спроектирован быстрым, поэтому перебор кандидатов идёт миллиардами в секунду, а без соли работают rainbow-таблицы и видны совпадающие пароли
CSHA-256 нельзя вычислить на сервере
DSHA-256 хранит пароль в открытом виде внутри хеша
2. Чем перец (pepper) отличается от соли (salt)?
AПерец хранится в базе рядом с хешем, а соль — нет
BПерец — это секрет, общий для всех паролей и хранимый отдельно от базы; соль — случайная и уникальная для каждого пользователя, хранится рядом с хешем
CПерец заменяет адаптивный хеш и делает соль ненужной
DЭто просто два названия одного и того же