Безопасное хранение паролей: соли, перцы и выбор алгоритма

от автора

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

Хеширование против шифрования

Первое, что нужно зафиксировать: пароли хешируются, а не шифруются. Это принципиальное различие, которое часто путают.

Шифрование — двусторонний процесс. Зашифрованное можно расшифровать при наличии ключа. Если ключ скомпрометирован или хранится рядом с данными, атакующий получает исходный текст паролей. Для паролей это недопустимо: приложению никогда не нужен исходный пароль — ему нужно лишь проверить, совпадает ли то, что ввёл пользователь, с тем, что было сохранено.

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

Отдельно стоит сказать о быстрых хеш‑функциях — SHA-256, SHA-512, MD5. Их использование для хранения паролей является грубой ошибкой. Эти алгоритмы проектировались для максимально быстрой работы, и современная GPU способна вычислять миллиарды таких хешей в секунду. MD5 и SHA-1 к тому же криптографически сломаны. Для паролей нужны специализированные алгоритмы, спроектированные быть намеренно медленными.

Соль и перец

Прежде чем переходить к алгоритмам, нужно понять два дополнительных механизма защиты — соль и перец.

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

Без соли один и тот же пароль всегда даёт один и тот же хеш. Это открывает дорогу к радужным таблицам (rainbow tables) — заранее вычисленным таблицам соответствий «пароль → хеш» для всех возможных комбинаций. Имея такую таблицу, атакующий «обращает» хеширование простым поиском: получает хеш из базы, находит его в таблице, читает пароль. Таблица всех возможных 8-символьных буквенно‑цифровых паролей содержит порядка 218 триллионов строк — большое, но вполне реальное число для современных хранилищ.

Соль это ломает. Каждый пользователь получает свою уникальную соль, и один и тот же пароль у двух пользователей даст два разных хеша. Чтобы атаковать базу с солями, атакующему пришлось бы строить отдельную rainbow table для каждой соли. При 64-битной соли размер такой таблицы для тех же 8-символьных паролей вырастает до 4×10³³ строк — это за пределами любых реальных возможностей. Современные библиотеки — passlib, Spring Security, bcrypt.js — генерируют и встраивают соль автоматически, вручную возиться с этим не нужно.

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

Перец разделяется между всеми пользователями — в отличие от соли. OWASP рекомендует реализовывать его через HMAC: хеш пароля выступает сообщением, секретное значение — ключом. Простая конкатенация password + pepper хуже, потому что граница между паролем и перцем может быть восстановлена при частичной утечке. Важное ограничение перца: его невозможно изменить без принудительного сброса паролей всем пользователям. Если перец скомпрометирован, атакующий узнаёт значение, применённое ко всем хешам в базе сразу.

Коэффициент сложности: как алгоритм адаптируется к железу

Все современные алгоритмы хранения паролей имеют настраиваемый коэффициент сложности, который определяет вычислительную стоимость одного хеша. Цель — сделать так, чтобы одна операция хеширования занимала 200–500 мс на сервере. Это достаточно медленно, чтобы brute‑force атака была экономически нецелесообразной, и достаточно быстро, чтобы легитимный пользователь при входе ничего не заметил.

Коэффициент сложности нужно периодически пересматривать — примерно раз в 2–3 года. Железо дешевеет и ускоряется, и то, что в 2020 году давало 300 мс, в 2026-м может отрабатывать за 50 мс. Хорошая практика — при каждом успешном входе пользователя проверять, соответствует ли его хеш актуальному work factor, и если нет — перехешировать «на лету».

Алгоритмы

Argon2id

Победитель Password Hashing Competition 2015 года, разработан командой под руководством Алексея Бирюкова, Даниэля Яна и Димитриса Худракиса, стандартизирован в RFC 9106. Текущая рекомендация OWASP и де‑факто стандарт для новых проектов.

Argon2 существует в трёх вариантах. Argon2d оптимизирован против GPU‑атак через зависящий от данных доступ к памяти, но именно поэтому уязвим к атакам через сторонние каналы. Argon2i использует независимый от данных доступ к памяти — устойчив к side‑channel, но в 2016 году академическая работа показала слабости против GPU‑атак, которые частично компенсировались изменением параметров. Argon2id — гибрид: первая половина итераций работает как Argon2i, вторая — как Argon2d. Для хранения паролей всегда используется Argon2id.

Главное преимущество Argon2 — высокие требования к памяти при вычислении хеша. Это делает параллельные атаки на GPU экономически невыгодными: GPU имеет ограниченный объём памяти на ядро, и если алгоритм требует 64 МБ на один хеш, одновременно атаковать тысячи хешей параллельно просто нечем.

Алгоритм имеет три параметра: память (m), итерации (t) и параллелизм (p). Минимальные параметры по OWASP: память 19 МБ, итерации 2, параллелизм 1. При наличии ресурсов рекомендуется поднять память до 64 МБ и итерации до 3.

from argon2 import PasswordHasherph = PasswordHasher(    time_cost=3,        # итерации    memory_cost=65536,  # 64 МБ в KiB    parallelism=4,    hash_len=32,    salt_len=16)# Хешируемhash = ph.hash("user_password")# Проверяемtry:    ph.verify(hash, "user_password")    # Опционально: перехешировать если параметры устарели    if ph.check_needs_rehash(hash):        hash = ph.hash("user_password")except Exception:    # Неверный пароль    pass
const argon2 = require('argon2');// Хешируемconst hash = await argon2.hash("user_password", {    type: argon2.argon2id,    memoryCost: 65536, // 64 MB    timeCost: 3,    parallelism: 4});// Проверяемconst match = await argon2.verify(hash, "user_password");
// Spring Security 6+PasswordEncoder encoder = new Argon2PasswordEncoder(    16,    // salt length    32,    // hash length    1,     // parallelism    65536, // memory in KB (64 MB)    3      // iterations);String hash = encoder.encode("user_password");boolean matches = encoder.matches("user_password", hash);

bcrypt

Разработан Нильсом Провосом и Дэвидом Мазьером в 1999 году на основе шифра Blowfish. Более 25 лет реального использования — серьёзный аргумент в его пользу. OWASP считает bcrypt приемлемым для существующих систем, для новых проектов рекомендует Argon2.

Параметр сложности bcrypt — степень двойки: значение 12 означает 2^12 = 4096 итераций. Минимум в 2026 году — 12, оптимально 13–14 на современном железе. Для ориентира: на 2 ГГц процессоре значение 12 даёт порядка 2–3 хешей в секунду, 13 — около 1 хеша в секунду.

Принципиальное ограничение bcrypt: максимальная длина входных данных — 72 байта. Всё, что длиннее, молча обрезается. Это создаёт проблему: пользователь устанавливает пароль длиной 80 символов, а хранится хеш только первых 72. Если хочется поддерживать длинные пароли, нужна предварительная обработка — но делать это нужно осторожно.

Распространённый неправильный подход: захешировать пароль через MD5 или SHA-256 и передать результат в bcrypt. Проблема в том, что SHA-256 возвращает бинарные данные, которые могут содержать нулевые байты, а bcrypt обрезает строку по первому нулевому байту — это приводит к реальным уязвимостям, известным как «password shucking». Правильный вариант — кодировать результат в Base64 или hex перед передачей в bcrypt.

import bcrypt# Хешируемpassword = "user_password".encode("utf-8")salt = bcrypt.gensalt(rounds=13)hash = bcrypt.hashpw(password, salt)# Проверяемbcrypt.checkpw(password, hash)  # True/False
const bcrypt = require("bcrypt");// Хешируемconst hash = await bcrypt.hash("user_password", 13);// Проверяемconst result = await bcrypt.compare("user_password", hash); // true/false
// Spring SecurityPasswordEncoder encoder = new BCryptPasswordEncoder(13);String hash = encoder.encode("user_password");boolean matches = encoder.matches("user_password", hash);

scrypt

Разработан Колином Персивалем в 2009 году для сервиса резервного копирования Tarsnap. Первый широко используемый алгоритм с явными требованиями к памяти — именно scrypt ввёл эту концепцию, которую позже развил Argon2.

Алгоритм генерирует в начале работы большой вектор псевдослучайных битовых последовательностей, а затем обращается к его элементам в псевдослучайном порядке, комбинируя их для получения ключа. Порядок обращений зависит от промежуточных результатов вычисления, поэтому каждое следующее обращение зависит от предыдущего — параллелизовать это затруднительно. Теоретически можно не хранить вектор в памяти, а пересчитывать каждый элемент в момент обращения, но scrypt специально настроен так, чтобы такая реализация была слишком медленной — баланс между памятью и временем намеренно невыгоден для безпамятных реализаций.

Параметры scrypt: N (степень двойки, определяет память и количество итераций), r (размер блока) и p (параллелизм). Потребление памяти оценивается как 128 × r × N байт. OWASP рекомендует N=2^17, r=8, p=1, что требует около 128 МБ памяти на хеш.

У scrypt есть существенный недостаток по сравнению с Argon2: три параметра взаимодействуют нелинейно, и неправильная конфигурация — особенно высокое p при малом N — может дать слабую защиту. При настройке на 1 мс операции алгоритм использует слишком мало памяти и становится слабее bcrypt при сопоставимой скорости. Argon2 имеет более предсказуемое поведение при настройке.

import os, hashlibpassword = b"user_password"salt = os.urandom(16)hash_bytes = hashlib.scrypt(    password,    salt=salt,    n=2**17,   # CPU/memory cost    r=8,       # block size    p=1,       # parallelism    dklen=32)# Сохранить salt + hash_bytes вместе

PBKDF2

Стандарт RSA Laboratories 2000 года (RFC 2898), основан на многократном применении HMAC. Итеративно применяет HMAC к паролю и соли — вычисляет U1, U2,…, Un, затем XOR всех промежуточных значений даёт итоговый ключ. Количество итераций напрямую контролирует вычислительную стоимость.

Главная слабость PBKDF2 — отсутствие требований к памяти при вычислении. GPU без каких‑либо ограничений по памяти может атаковать PBKDF2 параллельно, что делает его существенно слабее Argon2 при одинаковом времени вычисления. По оценкам, взлом 8-символьного пароля под Argon2id стоит атакующему от $500 000 на современном железе, тогда как под PBKDF2 — порядка $5 000.

Тем не менее PBKDF2 остаётся стандартом де‑факто в FIPS 140-2/140-3 средах. Если система работает в условиях требований FIPS‑совместимости, PBKDF2-HMAC‑SHA-256 с минимум 600 000 итерациями — единственный приемлемый выбор. В остальных случаях предпочтителен Argon2.

import hashlib, ospassword = b"user_password"salt = os.urandom(16)hash_bytes = hashlib.pbkdf2_hmac(    "sha256",    password,    salt,    iterations=600000,    dklen=32)

Распространённые ошибки

MD5 и SHA без итераций. Самая грубая ошибка — хранить md5(password) или sha256(password). Без замедления это миллиарды хешей в секунду на обычной GPU. Пин‑код из пяти цифр, захешированный SHA-512 без соли, гуглится напрямую — хеш просто будет в первых результатах поиска.

Статическая соль. Использовать одну и ту же соль для всех пользователей бессмысленно: rainbow table всё равно можно построить, просто один раз под эту конкретную соль. Два пользователя с одинаковым паролем получат одинаковый хеш — атакующий сразу это видит.

bcrypt(md5(password)). Выглядит как двойная защита, но MD5 снижает энтропию и создаёт проблему с нулевыми байтами в бинарном выходе — bcrypt обрежет строку по первому нулевому байту. Если нужна предобработка для длинных паролей — использовать Base64-кодирование результата SHA-256, не сырые байты.

Слишком высокие параметры как DoS‑вектор. Особенно актуально для scrypt: если N выставлен слишком высоко, атакующий может намеренно инициировать множество запросов на хеширование и исчерпать память сервера. Фактор сложности должен быть достаточным для защиты, но не настолько высоким, чтобы одна операция занимала несколько секунд.

Сравнение хешей через ==. Строковое сравнение уязвимо к атакам по времени: по времени ответа можно определить, насколько совпадает начало хеша. Использовать только constant‑time comparison — hmac.compare_digest() в Python, MessageDigest.isEqual() в Java.

import hmac# Небезопасноif stored_hash == computed_hash:    ...# Правильноif hmac.compare_digest(stored_hash.encode(), computed_hash.encode()):    ...

Миграция со старых алгоритмов

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

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

Итоговый выбор

Для нового проекта — Argon2id с памятью от 64 МБ, 3 итерациями, параллелизмом по числу доступных ядер. Соль генерируется автоматически библиотекой. Перец — опционально, через HMAC, если модель угрозы предполагает компрометацию базы без доступа к конфигурации сервера.

Для существующего проекта на bcrypt — фактор сложности не ниже 12, периодический пересмотр. Планировать миграцию на Argon2 при следующем крупном рефакторинге аутентификации.

FIPS‑среды — PBKDF2-HMAC‑SHA-256, 600 000 итераций.

MD5, SHA-1, SHA-256 и SHA-512 без итераций — никогда, ни при каких обстоятельствах.

ссылка на оригинал статьи https://habr.com/ru/articles/1051800/