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

Пароли

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

Проблем

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

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

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

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

Идеята

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

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

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

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

static string Sha256(string text) =>
Convert.ToHexStringLower(
SHA256.HashData(Encoding.UTF8.GetBytes(text)));
// Старият код на магазина: SHA-256 без salt.
static Row Register(string email, string password) =>
new(email, "", Sha256(password));

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

static int SharedRows(List<Row> users) =>
users.GroupBy(row => row.Hash)
.Where(group => group.Count() > 1)
.Sum(group => group.Count());
List<Row> users =
[
Register("ana@example.com", "qwerty"),
Register("boris@example.com", "sofia2024"),
Register("vera@example.com", "qwerty"),
Register("georgi@example.com", "Kp7#vR!q2Lm9"),
];
Console.WriteLine("Таблицата users:");
foreach (var row in users)
Console.WriteLine($"{row.Email,-20}{row.Hash[..16]}…");
Console.WriteLine($"Редове с общ hash: {SharedRows(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 за всеки ред.
static Row Register(string email, string password)
{
string salt = Convert.ToHexStringLower(
RandomNumberGenerator.GetBytes(16));
return new(email, salt, Sha256(salt + password));
}

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

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

List<Row> users =
[
Register("ana@example.com", "qwerty"),
Register("boris@example.com", "sofia2024"),
Register("vera@example.com", "qwerty"),
Register("georgi@example.com", "Kp7#vR!q2Lm9"),
];
Console.WriteLine($"Еднакъв запис за ana и vera: {users[0].Hash == users[2].Hash}");
Console.WriteLine($"Редове с общ hash: {SharedRows(users)}");
Изход
Еднакъв запис за ana и vera: False
Редове с общ hash: 0

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

Виж го

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

// демо; на истински сървър 600_000 (OWASP)
const int Iterations = 10_000;
static string HashPassword(string password)
{
byte[] salt = RandomNumberGenerator.GetBytes(16);
byte[] hash = Kdf(password, salt, Iterations);
return $"pbkdf2_sha256${Iterations}$"
+ $"{Convert.ToBase64String(salt)}$"
+ Convert.ToBase64String(hash);
}
static bool Verify(string password, string record)
{
string[] parts = record.Split('$');
int iterations = int.Parse(parts[1]);
byte[] salt = Convert.FromBase64String(parts[2]);
byte[] expected = Convert.FromBase64String(parts[3]);
byte[] actual = Kdf(password, salt, iterations);
return CryptographicOperations.FixedTimeEquals(
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: PBKDF2 повтаря HMAC-SHA256 10 000 пъти тук (демо). На истински сървър е 600 000.
  6. Записът пази всичко нужно за проверка: алгоритъм $ цена $ salt $ hash. Самата парола я няма в него.
  7. При вход кодът чете цената и salt-а от записа и hash-ира въведената парола по същия начин.
  8. Двата hash-а се сравняват за постоянно време: „qwerty“ дава ✓, „qwerty1“ дава ✗. Еднаквите пароли вече не личат, а всяко предположение е бавно.

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

Идеята

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

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

Стандартната библиотека има PBKDF2: HMAC-SHA256, повторен колкото итерации кажеш.
static byte[] Kdf(string password, byte[] salt,
int iterations) =>
Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations,
HashAlgorithmName.SHA256, 32);
Rfc2898DeriveBytes.Pbkdf2 е статичен метод от .NET 6 нататък: парола, salt, итерации, алгоритъм и дължина в байтове (32).

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

// демо; на истински сървър 600_000 (OWASP)
const int Iterations = 10_000;
static string HashPassword(string password)
{
byte[] salt = RandomNumberGenerator.GetBytes(16);
byte[] hash = Kdf(password, salt, Iterations);
return $"pbkdf2_sha256${Iterations}$"
+ $"{Convert.ToBase64String(salt)}$"
+ Convert.ToBase64String(hash);
}
static bool Verify(string password, string record)
{
string[] parts = record.Split('$');
int iterations = int.Parse(parts[1]);
byte[] salt = Convert.FromBase64String(parts[2]);
byte[] expected = Convert.FromBase64String(parts[3]);
byte[] actual = Kdf(password, salt, iterations);
return CryptographicOperations.FixedTimeEquals(
expected, actual);
}
string record = HashPassword("qwerty");
string[] parts = record.Split('$');
Console.WriteLine($"Алгоритъм и цена: {parts[0]}, {parts[1]}");
Console.WriteLine($"Вход с правилната парола: {Verify("qwerty", record)}");
Console.WriteLine($"Вход с грешна парола: {Verify("qwerty1", record)}");
Console.WriteLine($"Една парола, два записа, еднакви: {record == HashPassword("qwerty")}");
Изход
Алгоритъм и цена: pbkdf2_sha256, 10000
Вход с правилната парола: True
Вход с грешна парола: False
Една парола, два записа, еднакви: False

Цената в програмата е малка, за да върви бързо. На истински сървър OWASP препоръчва 600 000 итерации за PBKDF2-HMAC-SHA256. В истински проект обикновено не пишеш това сам, а ползваш bcrypt или argon2id от проверена библиотека (подробно в „По-точно“).

Пробвай

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

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

string first = HashPassword("qwerty");
string second = HashPassword("qwerty");
Console.WriteLine(first == second);
Console.WriteLine(Verify("qwerty", first));
Console.WriteLine(Verify("qwerty", second));
Отговор

Пробвай

Попълни

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

byte[] actual = Kdf(password, salt, iterations);
return CryptographicOperations.(
    expected, actual);

Пробвай

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

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

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

Виж го

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

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

// миналата година беше 10_000
const int Iterations = 20_000;
static int CostOf(string record) => int.Parse(record.Split('$')[1]);
static bool Login(Account account, string password)
{
if (!Verify(password, account.Record)) return false;
if (CostOf(account.Record) < Iterations)
{
// само сега паролата е в ръцете ни: hash с новата цена
account.Record = HashPassword(password, Iterations);
}
return true;
}
var account = new Account(HashPassword("qwerty", 10_000));
Console.WriteLine($"Цена преди входа: {CostOf(account.Record)}");
Console.WriteLine($"Вход: {Login(account, "qwerty")}");
Console.WriteLine($"Цена след входа: {CostOf(account.Record)}");
Изход
Цена преди входа: 10000
Вход: True
Цена след входа: 20000
По-точно, за любопитните

Какви настройки (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.

Какво ползват истинските проекти. Пишеш го сам рядко; ползваш библиотеката на рамката: ASP.NET Core Identity има PasswordHasher<TUser>: PBKDF2 със salt и цена в записа, и PasswordVerificationResult.SuccessRehashNeeded, който казва точно кога да се смята наново. За argon2id и bcrypt има пакети от NuGet.

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

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

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

Упражнение

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

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

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

  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 за достъпно обучение по програмиране ·