Хеширование паролей: соль и медленные хеши

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

Почему не открытым текстом

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

Проблема обычного хеша

Казалось бы, берём SHA-256 — и готово. Но есть две беды.

Беда 1: одинаковые пароли дают одинаковый хеш

import hashlib

def sha(p):
    return hashlib.sha256(p.encode()).hexdigest()

print("Аня пароль '123456':", sha("123456"))
print("Боря пароль '123456':", sha("123456"))
print("Хеши совпали -> видно, что у них одинаковый пароль")

Злоумышленник заранее считает хеши популярных паролей (это называется радужная таблица) и мгновенно узнаёт исходники.

Беда 2: SHA-256 слишком быстрый

Видеокарта считает миллиарды SHA-256 в секунду. Перебрать все короткие пароли — дело минут.

Решение 1: соль

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

import hashlib, secrets

def hash_with_salt(password):
    salt = secrets.token_bytes(16)              # уникальная соль
    h = hashlib.sha256(salt + password.encode()).hexdigest()
    return salt.hex(), h

salt_a, hash_a = hash_with_salt("123456")
salt_b, hash_b = hash_with_salt("123456")
print("Аня: ", hash_a)
print("Боря:", hash_b)
print("Тот же пароль — РАЗНЫЕ хеши:", hash_a != hash_b)

Решение 2: медленные хеши

Против перебора пароль нужно хешировать намеренно медленно. Специальные алгоритмы — bcrypt, scrypt, Argon2 — настраиваются так, чтобы один хеш считался, скажем, 0.1 секунды. Для входа это незаметно, а для перебора миллиардов вариантов — непреодолимо.

Покажем идею медленности «растягиванием» — много раундов хеша подряд:

import hashlib, secrets, time

def slow_hash(password, rounds=200_000):
    salt = secrets.token_bytes(16)
    h = password.encode()
    for _ in range(rounds):       # тысячи повторов = медленно
        h = hashlib.sha256(salt + h).digest()
    return h.hex()

t = time.time()
result = slow_hash("123456")
print("Хеш:", result[:32], "...")
print(f"Считался {time.time()-t:.3f} сек — намеренно медленно")
print("Это упрощённая идея PBKDF2/bcrypt/Argon2")

В реальном коде не пишите это руками — берите готовый bcrypt или argon2-cffi. Они правильно реализуют соль и стоимость.

Вывод:

Пароли хранят как хеш, а не открытым текстом. Обычный SHA-256 плох: одинаковые пароли дают одинаковый хеш и он слишком быстрый. Лечат солью (уникальной для каждого) и медленными хешами (bcrypt/scrypt/Argon2).
Проверьте себя
1. Зачем к паролю добавляют соль перед хешированием?
AЧтобы хеш стал короче
BЧтобы одинаковые пароли давали разные хеши и радужные таблицы не работали
CЧтобы пароль можно было восстановить обратно
DЧтобы ускорить вычисление хеша
2. Почему для паролей выбирают bcrypt или Argon2, а не обычный SHA-256?
AОни выдают более короткий хеш
BОни намеренно медленные, что делает перебор паролей крайне дорогим
CОни не требуют соли
DОни умеют расшифровывать пароль обратно
Поддержать проект