Към съдържанието
wannacode

Пароли

Накратко: Паролите се пазят като бавен hash със salt (bcrypt или argon2id), не криптирани и не с бърз SHA-256, за да не издаде изтичането на базата самите пароли.

Проблем

Резервното копие, което стана публично

Магазинът пази купувачите в таблица users: имейл и парола. Някой качва резервно копие на базата в публична папка и след седмица то е в интернет. Какво губят купувачите зависи само от това как е пазена паролата:

  • като текст: всички пароли са на открито. Много хора ползват една парола навсякъде, затова изтичат и пощите им;
  • криптирана с ключ, който стои до базата: същото. Ключът е в конфигурацията на същия сървър и изтича с нея;
  • като SHA-256 без salt: паролите не се виждат, но еднаквите пароли дават еднакъв hash. Самата таблица показва кой с кого споделя парола. А който има таблицата, може да вземе списък с често ползвани пароли, да смята hash-а на всяка и да търси съвпадения: това е атака с речник, а SHA-256 е направен да е бърз.

Изтичането не може да се изключи напълно. Затова паролата се пази така, че изтичането да не дава паролите.

Идеята

Пази се отпечатъкът, не паролата

Сървърът не трябва да знае паролата. Достатъчно е да може да провери дали въведената парола е същата. За това служи криптографският hash: функция, която прави от текста отпечатък с фиксирана дължина и не може да се обърне. Същият вход дава същия отпечатък, а от отпечатъка не се стига до входа.

Това не е hash() от урока за dict и set: той е бърз, къс и съвпада често, защото служи за кофи. Криптографският hash е друг вид.

Старият код на магазина пази SHA-256 на паролата:

class Row(NamedTuple):
email: str
salt: str
hash: str
def sha256(text):
return hashlib.sha256(text.encode()).hexdigest()
# Старият код на магазина: SHA-256 без salt.
def register(email, password):
return Row(email, "", sha256(password))

Четири купувачи; ana и vera имат една и съща парола. Функцията shared_rows брои редовете, чийто hash се среща повече от веднъж:

def shared_rows(users):
per_hash = Counter(row.hash for row in users)
return sum(1 for row in users if per_hash[row.hash] > 1)
users = [
register("ana@example.com", "qwerty"),
register("boris@example.com", "sofia2024"),
register("vera@example.com", "qwerty"),
register("georgi@example.com", "Kp7#vR!q2Lm9"),
]
print("Таблицата users:")
for row in users:
print(f"{row.email:<20}{row.hash[:16]}…")
print("Редове с общ hash:", shared_rows(users))
Изход
Таблицата users:
ana@example.com 65e84be33532fb78…
boris@example.com 6191711a1871a6bd…
vera@example.com 65e84be33532fb78…
georgi@example.com 9cbd9835924cf733…
Редове с общ hash: 2

Редовете на ana и vera са еднакви. Без да разбива нищо, всеки с таблицата вижда, че двете имат една парола. Ако едната я издаде, издава и другата.

Идеята

Salt: всеки ред различен

Поправката на еднаквите редове е salt: случайни байтове, свои за всеки ред, които се слагат до паролата преди hash-а. Salt-ът не е тайна. Той стои открито в реда, защото при вход трябва отново.

# Свой случаен salt за всеки ред.
def register(email, password):
salt = secrets.token_hex(16)
return Row(email, salt, sha256(salt + password))

secrets е криптографски генератор на случайни числа: следващата стойност не може да се предскаже. (Защо обикновеният генератор не става, е в урока за криптографията.)

Salt-ът е различен при всяко пускане, затова програмата печата само това, което не се мени:

users = [
register("ana@example.com", "qwerty"),
register("boris@example.com", "sofia2024"),
register("vera@example.com", "qwerty"),
register("georgi@example.com", "Kp7#vR!q2Lm9"),
]
print("Еднакъв запис за ana и vera:", users[0].hash == users[2].hash)
print("Редове с общ hash:", shared_rows(users))
Изход
Еднакъв запис за ana и vera: False
Редове с общ hash: 0

Еднаквите пароли вече не личат, а едно смятане важи само за един ред. Но SHA-256 си остава бърз: видеокарта смята милиарди в секунда, затова атаката с речник пак минава, просто ред по ред. Трябва и hash, който е бавен нарочно.

Виж го

Таблицата users: без salt, със salt, бавен hash

N = 2**12 # демо; на истински сървър 2**17 (OWASP)
def hash_password(password):
salt = secrets.token_bytes(16)
digest = kdf(password, salt, N)
return f"scrypt${N}${b64(salt)}${b64(digest)}"
def verify(password, record):
_, n, salt, expected = record.split("$")
actual = kdf(password, b64decode(salt), int(n))
return hmac.compare_digest(b64decode(expected), actual)
Стъпка по стъпка, с думи
  1. Таблицата users със стария код на магазина: имейл и SHA-256 на паролата, без salt.
  2. Еднаквите пароли дават еднакъв hash. ana и vera имат една и съща стойност, значи таблицата сама показва кой споделя парола: 2 реда.
  3. Сега всеки ред получава свой случаен salt, а редът пази SHA-256(salt + парола).
  4. Всеки ред е различен, и ana, и vera: 0 реда с общ hash. Но SHA-256 е бърз, затова всяко предположение за ред пак е евтино.
  5. Поправката е бавен hash: scrypt с цена N = 4 096 тук (демо), който иска и памет. На истински сървър N = 2^17.
  6. Записът пази всичко нужно за проверка: алгоритъм $ цена $ salt $ hash. Самата парола я няма в него.
  7. При вход кодът чете цената и salt-а от записа и hash-ира въведената парола по същия начин.
  8. Двата hash-а се сравняват за постоянно време: „qwerty“ дава ✓, „qwerty1“ дава ✗. Еднаквите пароли вече не личат, а всяко предположение е бавно.

Последният кадър събира трите стъпки: колоната пази запис със salt и цена, еднакви редове няма, а входът проверява паролата по записа.

Идеята

Бавен hash и записът

Бавният hash за пароли повтаря работата много пъти. Колко пъти е цената (cost, work factor). Входът смята веднъж и чака част от секундата. Който има изтеклата таблица, плаща същата цена за всяко свое предположение.

Стандартната библиотека има scrypt: освен време иска и памет, а паметта е скъпа за видеокартите.
def b64(data):
return b64encode(data).decode()
def kdf(password, salt, n):
return hashlib.scrypt(password.encode(), salt=salt,
n=n, r=8, p=1, dklen=32)
hashlib.scrypt взима n (цената, степен на двойката), r (размер на блока), p (паралелност) и dklen (дължина в байтове).

Записът пази всичко, което трябва за проверката: алгоритъм, цена, salt и hash, с $ между тях. Така цената може да се вдигне по-късно, без да се чупят старите записи.

N = 2**12 # демо; на истински сървър 2**17 (OWASP)
def hash_password(password):
salt = secrets.token_bytes(16)
digest = kdf(password, salt, N)
return f"scrypt${N}${b64(salt)}${b64(digest)}"
def verify(password, record):
_, n, salt, expected = record.split("$")
actual = kdf(password, b64decode(salt), int(n))
return hmac.compare_digest(b64decode(expected), actual)
record = hash_password("qwerty")
parts = record.split("$")
print(f"Алгоритъм и цена: {parts[0]}, {parts[1]}")
print("Вход с правилната парола:", verify("qwerty", record))
print("Вход с грешна парола:", verify("qwerty1", record))
print("Една парола, два записа, еднакви:", record == hash_password("qwerty"))
Изход
Алгоритъм и цена: scrypt, 4096
Вход с правилната парола: True
Вход с грешна парола: False
Една парола, два записа, еднакви: False

Цената в програмата е малка, за да върви бързо. На истински сървър OWASP препоръчва N = 2¹⁷ (r = 8, p = 1) за scrypt. В истински проект обикновено не пишеш това сам, а ползваш bcrypt или argon2id от проверена библиотека (подробно в „По-точно“).

Пробвай

Предвиди изхода

Една и съща парола, hash-ирана два пъти. Какво ще отпечата?

first = hash_password("qwerty")
second = hash_password("qwerty")
print(first == second)
print(verify("qwerty", first))
print(verify("qwerty", second))
Отговор

Пробвай

Попълни

При вход двата hash-а се сравняват. Обикновеното сравнение спира на първия различен байт, а по времето за отговор може да се познае докъде съвпадат. Кое сравнение минава винаги през всички байтове?

actual = kdf(password, b64decode(salt), int(n))
return hmac.(b64decode(expected), actual)

Пробвай

Намери грешката

Колега „опростява“ кода: вместо случаен salt за всеки ред, един salt за всички. Бавният hash е същият. Къде е проблемът?

Избери реда с грешката.

Виж го

Цената расте с годините

Компютрите стават по-бързи, затова цената, която е стигала миналата година, след време не стига. Старите записи не могат да се смятат наново: hash-ът не се връща обратно, а паролата сървърът я има само в момента на входа. Затова новият hash става при успешен вход. Тук hash_password получава цената като параметър, а записът пази своята, затова старите записи продължават да работят.

N = 2**12 # миналата година беше 2**11
def cost_of(record):
return int(record.split("$")[1])
def login(account, password):
if not verify(password, account.record):
return False
if cost_of(account.record) < N:
# само сега паролата е в ръцете ни: hash с новата цена
account.record = hash_password(password, N)
return True
account = Account(hash_password("qwerty", 2**11))
print("Цена преди входа:", cost_of(account.record))
print("Вход:", login(account, "qwerty"))
print("Цена след входа:", cost_of(account.record))
Изход
Цена преди входа: 2048
Вход: True
Цена след входа: 4096
По-точно, за любопитните

Какви настройки (OWASP, Password Storage Cheat Sheet):

  • argon2id: 19 MiB памет, 2 итерации, паралелност 1. Първият избор за нов проект;
  • scrypt: N = 2¹⁷, r = 8, p = 1. Иска 128 MiB памет, а и в Python, и в Node лимитът по подразбиране е 32 MiB, затова се вдига с maxmem;
  • bcrypt: цена 10 или повече. Взима само първите 72 байта от паролата;
  • PBKDF2-HMAC-SHA256: 600 000 итерации, когато трябва алгоритъм, одобрен от FIPS.

Какво ползват истинските проекти. Пишеш го сам рядко; ползваш библиотеката на рамката: Пакетите argon2-cffi и bcrypt (в стандартната библиотека ги няма). Django пази паролите точно във вида от този урок: pbkdf2_sha256$<итерации>$<salt>$<hash>, и смята наново при вход, когато цената се вдигне.

Pepper. Някои добавят и тайна стойност, обща за всички редове, която живее извън базата (в хранилище за тайни, виж урока за тайните). Изтичането само на базата тогава не стига. Помага, но не замества salt-а и бавния hash.

Политиката на паролите. Дълга парола (поне 12 знака) и проверка срещу списъци с изтекли пароли помагат повече от изисквания „главна буква и цифра“. Такава е и задачата по-долу.

Как е в другите езици: Java и C# имат PBKDF2 в стандартната библиотека, а scrypt нямат; Python и Node имат и двете (hashlib.pbkdf2_hmac, crypto.pbkdf2Sync). Никой от четирите няма argon2id или bcrypt вграден.

Упражнение

Смяна на паролата

Реши задачата в редактора си.

Сметката на купувача е с парола „qwerty“. Напиши change_password(account, old_password, new_password), която:

  1. проверява текущата парола с verify и отказва, ако е грешна;
  2. отказва нова парола, по-къса от 12 знака;
  3. отказва нова парола, която (без значение от малки и главни букви) е в списъка password1234, qwerty123456, 123456789012;
  4. иначе пази нов запис с нов salt и връща „Паролата е сменена“.

Извикай я с („qwerty1“, „new-pass-2026!“), („qwerty“, „short1“), („qwerty“, „Qwerty123456“) и („qwerty“, „tram-9-to-vitosha“), после провери входа с новата парола:

Изход
Грешна текуща парола
Новата парола е по-къса от 12 знака
Тази парола е в списъците с изтекли пароли
Паролата е сменена
Вход с новата парола: True

Още задачи

  • Нов hash при вход. Сложи в задачата и login от „Цената расте с годините“ и провери, че стара сметка с по-ниска цена излиза от входа с новата.
  • Еднакво време и при грешен имейл. Ако имейлът го няма, входът отговаря веднага, а ако го има, смята бавния hash. Как би направил двата случая да траят еднакво?

Тест

Провери се

1. Защо паролите не се пазят криптирани с ключ?
2. Какво прави salt-ът и какво НЕ прави?
3. Защо бавният hash помага, а не пречи?
4. Защо hash-ът при вход се сравнява за постоянно време?
5. Вдигаш цената от 10 000 на 20 000 итерации. Какво става със старите записи?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·