Безопасность
Хранение паролей: почему медленный, солёный, память-жёсткий KDF — единственный безопасный выбор
Уведомление об утечке приходит в пятницу. Атакующий выгрузил таблицу users — почты и хеши паролей. CTO задаёт один вопрос: «Насколько всё плохо?» Если хеши — это SHA-256, честный ответ: «все слабые и средние пароли уже взломаны». Одна RTX 4090 перемалывает около 22 миллиардов SHA-256-догадок в секунду; против утёкшего словаря плюс правил частые пароли падают за минуты, остальные — за выходные. Если бы хеши были Argon2id с солью по пользователю, ответ был бы: «получат пару тривиально слабых и сдадутся — каждая догадка стоит им реальной RAM и времени». Та же утечка — два совершенно разных понедельника. Разница была решена годами раньше, в одной строке кода регистрации.
Быстрые хеши — неправильный инструмент
SHA-256, SHA-512 и MD5 спроектированы быть быстрыми — они существуют, чтобы снимать отпечатки файлов и проверять целостность на гигабайтах в секунду. Для паролей быстрота — это баг, а не фича. Модель угроз — офлайн-взлом: как только у атакующего есть твоя колонка хешей, он больше не ограничен скоростью твоего эндпоинта логина. Он гоняет догадки локально, так быстро, как позволяет железо, против утёкших хешей.
Числа жестокие. На одном потребительском GPU (RTX 4090) бенчмарки hashcat дают около 164 GH/s для MD5 и ~22 GH/s для SHA-256 — это 22 000 000 000 догадок в секунду, на одну карту. Арендуй восемь в облаке — и ты на ~180 миллиардах SHA-256/сек. Против стандартных словарей (rockyou плюс правила мутаций) это значит, что любой пароль, который является словарным словом, именем плюс год или частым паттерном, восстанавливается практически мгновенно. Хеш общего назначения даёт атакующему ровно то, в чём ему надо отказать: пропускную способность.
Фикс — сделать каждую отдельную догадку намеренно дорогой. Парольный KDF (функция выведения ключа) — это хеш, специально спроектированный быть медленным и прожорливым к ресурсам, чтобы одна проверка при логине была дешёвой (несколько сотен миллисекунд, однократно), но миллиард офлайн-догадок стал экономически невозможным. Ты не пытаешься быть невзламываемым; ты пытаешься сделать взлом дороже, чем стоят учётки.
Соль: по пользователю, случайная, бьёт предвычисление
До медленности — почини уникальность. Два пользователя с паролем Summer2024! не должны иметь одинаковый хеш — если так, то атакующий, взломавший одного, взламывает обоих, и хуже того, может применить радужную таблицу: гигантскую предвычисленную карту хеш → пароль, построенную один раз и переиспользуемую против любой несолёной базы вечно.
Соль — это случайное значение (≥16 байт, из CSRNG), генерируемое на пользователя и хранимое рядом с хешем. Ты хешируешь salt + password вместо password. Теперь одинаковые пароли дают разные хеши, радужные таблицы бесполезны (атакующему понадобилась бы отдельная таблица на каждую соль), и атакующий вынужден взламывать каждую учётку по отдельности. Соль не секрет — она живёт в той же строке, что и хеш, и это нормально; её задача — уникальность, а не сокрытие. Современные KDF генерируют и встраивают соль за тебя, кодируя её прямо внутри выходной строки, так что ты хранишь одно поле, а не два.
| Подход | Скорость офлайн-догадок (1 GPU) | Вердикт |
|---|---|---|
md5(password) | ~164 миллиарда/сек | Катастрофа — ещё и радужно-таблично |
sha256(password) | ~22 миллиарда/сек | Катастрофа — быстро = неправильный инструмент |
sha256(salt + password) | ~22 миллиарда/сек | Соль бьёт радужные таблицы, но всё равно слишком быстро |
bcrypt(password) cost 12 | ~десятки тысяч/сек | Приемлемо — медленно + солёно по дизайну |
argon2id m=19 MiB, t=2 | ~тысячи/сек, нужно 19 MiB RAM на каждую | Предпочтительно — медленно + солёно + память-жёстко |
Три принятых алгоритма и числа OWASP
Password Storage Cheat Sheet от OWASP называет ровно три современных варианта. Используй реальные, именованные параметры — а не дефолты, которые ты ни разу не проверял.
- Argon2id (предпочтительно). Победитель Password Hashing Competition 2015; стандартизирован в RFC 9106. Он память-жёсткий: каждая догадка обязана выделить заданный блок RAM, и это калечит атаки GPU и ASIC (у GPU тысячи ядер, но ограниченная пропускная способность памяти, поэтому он не может гонять тысячи прожорливых к памяти догадок параллельно). Минимум OWASP: m=19456 (19 MiB), t=2, p=1. Вариант
idсмешивает проходы, независимые и зависимые от данных, ради устойчивости и к GPU, и к side-channel — выбирайid, а неiилиd. - scrypt. Тоже память-жёсткий, появился раньше Argon2. Параметры OWASP: N=2^17, r=8, p=1. Надёжный выбор там, где Argon2 недоступен.
- bcrypt (legacy, всё ещё приемлемо). На основе Blowfish, медленный и солёный, без память-жёсткости. Минимум cost factor 10 (каждый +1 удваивает работу). Нормально для существующих систем; для нового кода предпочитай Argon2id.
PBKDF2 существует для FIPS-сред (600 000+ итераций с HMAC-SHA-256), но он не память-жёсткий и слабейший из четырёх против GPU. Тянись к нему только когда режим комплаенса вынуждает.
Ловушка 72 байт у bcrypt
У bcrypt есть острый край, который вызывал реальные обходы аутентификации в проде: он молча игнорирует всё после первых 72 байт ввода. Это идёт от лимита key-schedule у Blowfish. Поэтому correct horse battery staple ...(73+ символов) и другая строка, отличающаяся только с 73-го символа, хешируются в одно и то же значение. В 2024 это поспособствовало инцидентам, где слишком длинные вводы схлопывались в совпадающие хеши, ослабляя аутентификацию.
Становится хуже, если попытаться «починить» лимит в 72 байта предхешированием быстрым хешем и затем base64-кодированием: дайджест SHA-256 — это 32 байта, но его hex/base64-форма может быть 44–64 байта, и если ещё дописать другие данные, можно снова перевалить за 72 — заново обрезая ту самую энтропию, которую пытался сохранить. Правильный паттерн, когда нужна поддержка длинных паролей на bcrypt, — предхешировать SHA-256 и передавать сырые байты (или base64, остающийся ≤72 байт), или проще: использовать Argon2id, у которого такого лимита нет. Как минимум — валидируй длину ввода и отклоняй или предхешируй до того, как bcrypt его увидит.
Почему это работает
Почему память-жёсткость важнее голой медленности: преимущество атакующего — параллелизм. GPU гоняет тысячи догадок одновременно, поэтому чисто CPU-медленный хеш (вроде высокого числа итераций PBKDF2) всё равно делится по ядрам. Память-жёсткость меняет экономику — если каждой догадке нужно 19 MiB быстрой RAM, карта с ограниченной пропускной способностью памяти может гонять лишь несколько параллельно, а не тысячи. Ты бьёшь по аппаратному преимуществу атакующего напрямую, а не только по тактовой частоте.
Work factor должен расти со временем, плюс pepper
Твои параметры стоимости — не «один раз и забыл». Железо ускоряется каждый год, поэтому стоимость, которая занимала 250 мс в 2020, сегодня дёшева. Относись к work factor как к значению, которое поднимаешь по расписанию — и стандартное место для этого — при логине: когда пользователь успешно аутентифицируется и ты замечаешь, что его сохранённый хеш использует устаревшие параметры, прозрачно перехешируй его plaintext (который у тебя есть на этот один запрос) с новой стоимостью и обнови строку. За недели вся таблица мигрирует без принудительных сбросов.
Ещё два защитных слоя, которые добавляет сеньор. Pepper — это единое секретное значение, подмешиваемое в каждый хеш (часто через HMAC), но хранимое вне базы — в KMS, env-переменной или HSM. Это defense-in-depth: если утекла только БД (частый случай), а секрет приложения нет, у атакующего есть солёные хеши без pepper, и он не может взломать их вовсе. Наконец, при проверке логина используй сравнение за константное время для любой проверки равенства, которую делаешь сам, чтобы потраченное время не сливало, сколько ведущих байт совпало — хотя учти, что стандартные verify-функции (bcrypt.compare, verify у Argon2) уже делают это за тебя. Не пиши storedHash === computedHash руками.
Ты строишь auth для нового сервиса. Выбери, как хранить пароли.
Почему SHA-256 — неправильный выбор для хранения паролей, даже с солью по пользователю?
Какова роль соли по пользователю, и секретна ли она?
Расставь шаги безопасного хранения пароля нового пользователя:
- 1 Сгенерируй случайную соль по пользователю из CSRNG (обычно KDF делает это за тебя)
- 2 Прогони память-жёсткий KDF (Argon2id m=19 MiB, t=2, p=1) над salt + password
- 3 Сохрани закодированную выходную строку (алгоритм + параметры + соль + хеш) в одну колонку
- 4 На каждом логине проверяй constant-time функцией сравнения у KDF
- 5 При успешном логине с устаревшими параметрами прозрачно перехешируй с новым work factor
- 01Инженер говорит: «мы солим наш SHA-256, значит хранение паролей решено». Объясни, почему это всё ещё серьёзная проблема и что менять.
- 02В чём разница между солью и pepper, и где живёт каждая?
Хранение паролей решается одним принципом: атака, от которой защищаешься, — это офлайн-взлом, где у атакующего есть твоя колонка хешей и он гонит догадки на полной скорости железа — около 22 миллиардов SHA-256 в секунду на одном GPU. Это делает быстрый хеш общего назначения полностью неправильным инструментом; его скорость — ровно то, что теряет базу. Ответ — намеренно медленная, память-жёсткая функция выведения ключа. Соли каждый пароль случайным значением по пользователю, чтобы бить радужные таблицы и вынуждать взлом по учёткам — соль не секретна и живёт рядом с хешем. Затем выбери проверенный KDF с именованными параметрами: Argon2id (предпочтительно, RFC 9106, OWASP m=19 MiB, t=2, p=1) ради память-жёсткости, калечащей параллелизм GPU, scrypt (N=2^17) или bcrypt (cost 10+, помня о ловушке обрезки 72 байт). Поднимай work factor по мере улучшения железа, прозрачно перехешируя при логине. Добавь pepper, хранимый вне базы, ради defense-in-depth, и всегда проверяй constant-time сравнением библиотеки, а не своей самописной проверкой равенства.