Хеширование паролей: соль, перец и выбор алгоритма на Python

Монитор с размытым кодом и металлический замок на рабочем столе разработчика Безопасность

Хеширование паролей нужно, чтобы в базе данных лежал не сам пароль и не его шифр, а хеш медленной функции с солью: Argon2id, scrypt, bcrypt или PBKDF2. При входе сервер считает хеш введённого пароля с той же солью и настройками и сравнивает результат с записью. Быстрые MD5 и SHA-256 для этого не годятся даже с солью. Код ниже прогнан на Python 3.14.3 (Windows 10, OpenSSL 3.0.18) с пакетами argon2-cffi 25.1.0 и bcrypt 5.0.0, вывод настоящий.

Зачем хешировать пароли и почему не шифровать

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

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

Почему MD5 и SHA-256 не подходят для паролей

Криптографические хеш-функции общего назначения вроде MD5 и SHA-256 считаются быстро. Для хеширования паролей это плохо: та же скорость позволяет злоумышленнику проверять очень много вариантов при атаке по словарю. Вторая проблема видна на трёх пользователях:

import hashlib

for user, password in [("anna", "sunrise7"), ("oleg", "sunrise7"), ("ivan", "Sunrise7")]:
    print(user, hashlib.sha256(password.encode()).hexdigest()[:16])
anna b9d3afe2cf79d92e
oleg b9d3afe2cf79d92e
ivan 53c060860bf99b62

Алгоритм без соли всегда даёт одинаковый хеш для одного пароля, поэтому по дампу базы данных сразу видно, что у двух пользователей пароль общий. Хеши популярных паролей можно посчитать заранее и потом просто искать в готовых базах: так работают таблицы готовых хешей, в том числе радужные. У MD5 и SHA-1 вдобавок известны коллизии, но для хранения паролей все три алгоритма не годятся уже из-за скорости.

Мы бы не советовали хранить sha256(соль + пароль) даже в учебном проекте. Соль закрывает заранее посчитанные таблицы, а скорость перебора остаётся прежней.

Открытый корпус компьютера с несколькими видеокартами в ряд

Соль: случайная строка для каждого пароля

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

Соль не секрет, её хранят рядом с хешем, иначе сервер не сможет проверить пароль. Соль берите не короче 32 бит, а лучше 128 бит (16 байт). Источник случайных данных нужен криптостойкий: os.urandom() или модуль secrets; модуль random для этого не предназначен.

Вручную соль почти не генерируют. Argon2-cffi, bcrypt и большинство распространённых библиотек создают её сами и кладут в строку хеша. Из стандартной библиотеки соль вручную генерируют для hashlib.pbkdf2_hmac() и hashlib.scrypt().

Солонка рядом с клавиатурой ноутбука

Какой алгоритм хеширования паролей выбрать

Хорошая функция хеширования паролей настраивается, работает медленно и использует соль. Argon2 и scrypt вдобавок требуют много памяти, а такие функции дают дополнительную защиту от перебора на специализированном оборудовании.

Алгоритм Когда брать Минимальные настройки В Python
Argon2id новый проект m=19456 (19 МиБ), t=2, p=1 пакет argon2-cffi
scrypt Argon2id недоступен n=2^17 (128 МиБ), r=8, p=1 hashlib.scrypt()
bcrypt уже стоит в старой системе rounds не меньше 10 пакет bcrypt
PBKDF2-HMAC-SHA256 нужна сертификация FIPS-140 600 000 итераций hashlib.pbkdf2_hmac()

У Argon2id есть равноценные наборы: чем меньше памяти, тем больше итераций, например m=12288 (12 МиБ) с t=3 или m=7168 (7 МиБ) с t=5, все с p=1. У scrypt так же: n=2^16 с p=2, n=2^15 с p=3. PBKDF2 на SHA-512 хватит 220 000 итераций, на SHA-1 нужно 1 400 000, но вариант с SHA-1 оставляют только для старых систем.

Читайте также:  SQL-инъекция: как работает и как защититься параметризованными запросами

Для нового приложения берите алгоритм хеширования Argon2id. Если ставить сторонние пакеты нельзя, scrypt из стандартной библиотеки. Bcrypt оставьте там, где он уже работает, а PBKDF2 там, где требование сертификации не обойти.

Argon2id в Python

Пакет ставится командой pip install argon2-cffi. Класс PasswordHasher по умолчанию использует Argon2id с t=3, m=65536 КиБ (64 МиБ), p=4, солью 16 байт и хешем 32 байта: это рекомендуемый набор для серверов с ограниченной памятью.

from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

hasher = PasswordHasher()  # Argon2id, настройки по умолчанию
first, second = hasher.hash("sunrise7"), hasher.hash("sunrise7")
print(first)
print("хеши равны:", first == second)

for attempt in ("sunrise7", "sunrise8"):
    try:
        print(attempt, hasher.verify(first, attempt))
    except VerifyMismatchError:
        print(attempt, False)
$argon2id$v=19$m=65536,t=3,p=4$0VKt62DuPvWVbEucBgEh8w$FKVSsD1nTr/GbxiCeJaT/hTb3oreJhwOQVkLF/cVaZA
хеши равны: False
sunrise7 True
sunrise8 False

Один и тот же пароль дал два разных хеша: у каждого своя соль. Строка хеша содержит всё для проверки: алгоритм, версию, настройки, соль (22 символа base64) и само значение хеша (43 символа). В базе данных хватит одного текстового поля, отдельная колонка для соли не нужна.

При неверном пароле verify() бросает VerifyMismatchError, его и ловим. Остальные исключения, например InvalidHashError для испорченной строки в базе, мы бы не глушили: такую запись нужно увидеть в логе.

bcrypt и лимит 72 байта

Пакет bcrypt тоже хранит соль и число раундов в одной строке с хешем, это видно по началу $2b$12$.

import bcrypt

hashed = bcrypt.hashpw(b"sunrise7", bcrypt.gensalt())  # 12 раундов по умолчанию
print(hashed.decode())
print(bcrypt.checkpw(b"sunrise7", hashed), bcrypt.checkpw(b"sunrise8", hashed))

long_password = ("я" * 37).encode()
print(len(long_password), "байта")
try:
    bcrypt.hashpw(long_password, bcrypt.gensalt())
except ValueError as error:
    print("ValueError:", error)
$2b$12$cp2Jzbpd0VKgDuITEFEOaO.E8Cq/niaoepAuUkWdaxvd6kXWe3ijy
True False
74 байта
ValueError: password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])

Bcrypt принимает не больше 72 байт. Русская буква в UTF-8 занимает 2 байта, поэтому пароль из 37 букв «я» уже не проходит. С версии 5.0.0 пакет бросает на таком пароле ValueError, а более ранние версии, по описанию изменений версии 5.0.0, молча отрезали хвост. Длину проверяйте в байтах (len(password.encode()) <= 72) ещё при регистрации.

Читайте также:  Хеш-функция: что это, свойства, коллизии и примеры на Python

Обходить лимит предварительным SHA-256 опасно: исходный bcrypt останавливается на нулевом байте, а в двоичном хеше он может встретиться. Проще взять Argon2id: в нашем прогоне argon2-cffi принял и проверил пароль из 1000 букв «я».

PBKDF2 и scrypt из стандартной библиотеки

Без сторонних пакетов в Python есть две медленные функции хеширования: hashlib.scrypt() и hashlib.pbkdf2_hmac(). Соль для них берите из os.urandom(16), а рядом с хешем храните соль и число итераций, иначе после смены настроек старые записи не проверить. Хеши сравнивайте через hmac.compare_digest(): в отличие от ==, функция не обрывает сравнение на первом несовпадении, чтобы время ответа не выдавало содержимое. Полный пример со scrypt и ловушкой maxmem (с n=2^17 и r=8 вызов без него падает с ValueError ... memory limit exceeded) есть в статье о хеш-функциях.

Экран редактора кода с размытыми строками на Python и терминалом снизу

Как подобрать настройки: замер на своём сервере

Настройки хеширования подбирают на той машине, где будет работать проверка пароля: как можно выше, но чтобы вход не тормозил. Ориентир по времени: хеш одного пароля должен считаться меньше секунды. Каждый алгоритм замерен на Intel Core i9-9900KF, взят лучший из пяти вызовов:

import hashlib, os, timeit
import bcrypt
from argon2 import PasswordHasher

password, salt = b"sunrise7", os.urandom(16)
tests = {
    "Argon2id m=19456,t=2,p=1": lambda: PasswordHasher(2, 19456, 1).hash(password),
    "Argon2id m=65536,t=3,p=4": lambda: PasswordHasher().hash(password),
    "scrypt n=2**17,r=8,p=1": lambda: hashlib.scrypt(
        password, salt=salt, n=2**17, r=8, p=1, maxmem=2**28),
    "bcrypt 12": lambda: bcrypt.hashpw(password, bcrypt.gensalt(12)),
    "bcrypt 13": lambda: bcrypt.hashpw(password, bcrypt.gensalt(13)),
    "PBKDF2-SHA256 600 000": lambda: hashlib.pbkdf2_hmac("sha256", password, salt, 600_000),
}
for name, func in tests.items():
    print(f"{name}: {min(timeit.repeat(func, number=1, repeat=5)) * 1000:.0f} мс")
Argon2id m=19456,t=2,p=1: 22 мс
Argon2id m=65536,t=3,p=4: 46 мс
scrypt n=2**17,r=8,p=1: 387 мс
bcrypt 12: 189 мс
bcrypt 13: 373 мс
PBKDF2-SHA256 600 000: 373 мс

На вашей машине цифры будут другими. Память считайте вместе со временем: с настройками по умолчанию один хеш занимает 64 МиБ, и 100 одновременных входов займут около 6,25 ГиБ. Число раундов у bcrypt задаёт степень двойки: в замере шаг с 12 на 13 удвоил время.

Как проверяется пароль при входе

  1. Пользователь отправляет логин и пароль.
  2. Сервер достаёт из базы данных строку хеша этого пользователя.
  3. Библиотека берёт из строки алгоритм, настройки и соль, считает хеш введённого пароля и сравнивает с сохранённым.
  4. Совпало: сервер открывает сессию или выдаёт токен. Не совпало: возвращает ошибку.
  5. Если настройки в строке хеша устарели, сервер пересчитывает хеш, пока пароль в памяти, и записывает новый.

Перец: секрет вне базы данных

Перцем называют общий для всех паролей секретный ключ, который хранят отдельно от базы данных: в хранилище секретов или аппаратном модуле (HSM). Это второй уровень защиты на случай, когда злоумышленник смог получить только базу, например через SQL-инъекцию или украденный бэкап. Пароль сначала проходит через HMAC-SHA256 с перцем в роли ключа, потом результат хешируется медленным алгоритмом. С bcrypt этот приём не повторяйте: в выходе HMAC бывают нулевые байты.

Перец генерируйте модулем secrets (secrets.token_bytes(32)), как пароли в генераторе паролей на Python. Сменить его можно только вместе с паролями всех пользователей: пересчитать хеш без исходного пароля невозможно. Потеряете перец, и войти не сможет никто. Храните его так же бережно, как ключ подписи JWT-токенов, и не кладите в репозиторий.

Читайте также:  XSS атака: что это такое и как защитить свой код

Металлический сейф в серверной рядом со стойкой

Перехеширование и миграция старых хешей MD5

Настройки со временем поднимают, потому что оборудование становится мощнее и дешевле. Исходного пароля у сервера нет, поэтому пересчитать хеш можно только в момент входа. Для этого у PasswordHasher есть check_needs_rehash(): он сравнивает настройки из строки хеша с настройками объекта.

Со старой базой данных, где лежит md5(пароль), ждать входа каждого пользователя опасно: кто не заходит годами, так и останется на MD5. Лучше сразу обернуть все старые хеши в медленный алгоритм одним скриптом, исходный пароль для этого не нужен. Так алгоритм хеширования меняется без сброса паролей, а при следующем входе запись переходит на чистую схему. Функция входа ниже собирает всё вместе: перец, обёрнутый MD5 у anna и старые настройки у oleg.

import hashlib, hmac, os
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

hasher = PasswordHasher()  # новые настройки: m=65536, t=3, p=4
old_hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
# В проде перец берут из хранилища секретов, здесь для примера из окружения.
PEPPER = os.environ.get("PASSWORD_PEPPER", "dev-pepper").encode()

def peppered(text):
    return hmac.new(PEPPER, text.encode(), hashlib.sha256).digest()

def md5(text):
    return hashlib.md5(text.encode()).hexdigest()

users = {
    "anna": "md5:" + hasher.hash(peppered(md5("sunrise7"))),  # обёрнутый MD5
    "oleg": old_hasher.hash(peppered("river42")),  # старые настройки
}

def login(user, password):
    stored = users[user]
    legacy = stored.startswith("md5:")
    try:
        hasher.verify(stored.removeprefix("md5:"),
                      peppered(md5(password) if legacy else password))
    except VerifyMismatchError:
        return False  # неверный пароль запись не трогает
    if legacy or hasher.check_needs_rehash(stored):
        users[user] = hasher.hash(peppered(password))
    return True

print("oleg до:", users["oleg"][:30])
print("anna неверный:", login("anna", "sunrise8"), users["anna"][:12])
print("anna верный:", login("anna", "sunrise7"), users["anna"][:12])
print("oleg верный:", login("oleg", "river42"), users["oleg"][:30])
try:
    hasher.verify(users["anna"], "sunrise7")  # дамп базы без перца
    print("без перца: подходит")
except VerifyMismatchError:
    print("без перца: не подходит")
oleg до: $argon2id$v=19$m=19456,t=2,p=1
anna неверный: False md5:$argon2i
anna верный: True $argon2id$v=
oleg верный: True $argon2id$v=19$m=65536,t=3,p=4
без перца: не подходит

Метка md5: говорит коду, что пароль сначала нужно прогнать через MD5. Запись в users меняется только после успешной проверки, поэтому неверный пароль её не трогает. Без перца проверка не проходит: в базе лежит хеш от HMAC с перцем, и голый пароль с ним не совпадает.

Обёртка слабее чистого Argon2id, держите её временной мерой. Тем, кто не входил очень долго, хеши можно удалить и потребовать сброс пароля при следующем входе.

Старый жёсткий диск и новый SSD рядом на рабочем столе

Частые вопросы

Можно ли расшифровать хеш пароля?

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

Где хранить соль?

Рядом с хешем, в той же записи базы данных. Argon2-cffi и bcrypt кладут соль прямо в строку хеша. Секретом соль не считается, секретом бывает перец, и его храните вне базы.

Сколько раундов bcrypt ставить?

Не меньше 10, а по умолчанию пакет ставит 12. В нашем замере 12 раундов заняли 189 мс, 13 раундов 373 мс, то есть вдвое дольше. Поднимайте, пока хеш на вашем сервере считается меньше секунды и вход не тормозит.

Какую длину пароля разрешать?

Не меньше 15 символов, если пароль единственный фактор входа, и не меньше 8 в многофакторной схеме. Максимум ставьте не ниже 64 символов, а смесь разных типов символов (цифры, заглавные буквы) не требуйте. С bcrypt учитывайте лимит 72 байта: 64 русские буквы в него не войдут.

Оцените статью
bestprogrammer.ru
Добавить комментарий