Пароли
Накратко: Паролите се пазят като бавен hash със salt (bcrypt или argon2id), не криптирани и не с бърз SHA-256, за да не издаде изтичането на базата самите пароли.
Проблем
Резервното копие, което стана публично
Магазинът пази купувачите в таблица users: имейл и парола. Някой качва резервно копие на базата в публична папка и след седмица то е в интернет. Какво губят купувачите зависи само от това как е пазена паролата:
- като текст: всички пароли са на открито. Много хора ползват една парола навсякъде, затова изтичат и пощите им;
- криптирана с ключ, който стои до базата: същото. Ключът е в конфигурацията на същия сървър и изтича с нея;
- като SHA-256 без salt: паролите не се виждат, но еднаквите пароли дават еднакъв hash. Самата таблица показва кой с кого споделя парола. А който има таблицата, може да вземе списък с често ползвани пароли, да смята hash-а на всяка и да търси съвпадения: това е атака с речник, а SHA-256 е направен да е бърз.
Изтичането не може да се изключи напълно. Затова паролата се пази така, че изтичането да не дава паролите.
Идеята
Пази се отпечатъкът, не паролата
Сървърът не трябва да знае паролата. Достатъчно е да може да провери дали въведената парола е същата. За това служи криптографският hash: функция, която прави от текста отпечатък с фиксирана дължина и не може да се обърне. Същият вход дава същия отпечатък, а от отпечатъка не се стига до входа.
Това не е hash-ът от урока за Map и Set: той е бърз, къс и съвпада често, защото служи за кофи. Криптографският hash е друг вид.Старият код на магазина пази SHA-256 на паролата:
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
// Старият код на магазина: SHA-256 без salt.function register(email, password) { return { email, salt: "", hash: sha256(password) };}Четири купувачи; ana и vera имат една и съща парола. Функцията sharedRows брои редовете, чийто hash се среща повече от веднъж:
function sharedRows(users) { const perHash = new Map(); for (const { hash } of users) { perHash.set(hash, (perHash.get(hash) ?? 0) + 1); } return users.filter((row) => perHash.get(row.hash) > 1).length;}const users = [ register("ana@example.com", "qwerty"), register("boris@example.com", "sofia2024"), register("vera@example.com", "qwerty"), register("georgi@example.com", "Kp7#vR!q2Lm9"),];console.log("Таблицата users:");for (const row of users) { console.log(row.email.padEnd(20) + row.hash.slice(0, 16) + "…");}console.log("Редове с общ 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 за всеки ред.function register(email, password) { const salt = randomBytes(16).toString("hex"); return { email, salt, hash: sha256(salt + password) };}randomBytes от node:crypto е криптографски генератор на случайни числа: следващата стойност не може да се предскаже. (Защо обикновеният генератор не става, е в урока за криптографията.)
Salt-ът е различен при всяко пускане, затова програмата печата само това, което не се мени:
const users = [ register("ana@example.com", "qwerty"), register("boris@example.com", "sofia2024"), register("vera@example.com", "qwerty"), register("georgi@example.com", "Kp7#vR!q2Lm9"),];console.log("Еднакъв запис за ana и vera:", users[0].hash === users[2].hash);console.log("Редове с общ hash:", sharedRows(users));Еднакъв запис за ana и vera: falseРедове с общ hash: 0Еднаквите пароли вече не личат, а едно смятане важи само за един ред. Но SHA-256 си остава бърз: видеокарта смята милиарди в секунда, затова атаката с речник пак минава, просто ред по ред. Трябва и hash, който е бавен нарочно.
Виж го
Таблицата users: без salt, със salt, бавен hash
// демо; на истински сървър 2 ** 17 (OWASP)const N = 2 ** 12;
function hashPassword(password) { const salt = randomBytes(16); const hash = kdf(password, salt, N); return ["scrypt", N, salt.toString("base64"), hash.toString("base64")].join("$");}
function verify(password, record) { const [, n, salt, expected] = record.split("$"); const actual = kdf(password, Buffer.from(salt, "base64"), Number(n)); const want = Buffer.from(expected, "base64"); return timingSafeEqual(actual, want);}const sha256 = (text) => createHash("sha256").update(text).digest("hex");
// Старият код на магазина: SHA-256 без salt.function register(email, password) { return { email, salt: "", hash: sha256(password) };}// Свой случаен salt за всеки ред.function register(email, password) { const salt = randomBytes(16).toString("hex"); return { email, salt, hash: sha256(salt + password) };}// демо; на истински сървър 2 ** 17 (OWASP)const N = 2 ** 12;
function hashPassword(password) { const salt = randomBytes(16); const hash = kdf(password, salt, N); return ["scrypt", N, salt.toString("base64"), hash.toString("base64")].join("$");}
function verify(password, record) { const [, n, salt, expected] = record.split("$"); const actual = kdf(password, Buffer.from(salt, "base64"), Number(n)); const want = Buffer.from(expected, "base64"); return timingSafeEqual(actual, want);}Стъпка по стъпка, с думи
- Таблицата users със стария код на магазина: имейл и SHA-256 на паролата, без salt.
- Еднаквите пароли дават еднакъв hash. ana и vera имат една и съща стойност, значи таблицата сама показва кой споделя парола: 2 реда.
- Сега всеки ред получава свой случаен salt, а редът пази SHA-256(salt + парола).
- Всеки ред е различен, и ana, и vera: 0 реда с общ hash. Но SHA-256 е бърз, затова всяко предположение за ред пак е евтино.
- Поправката е бавен hash: scrypt с цена N = 4 096 тук (демо), който иска и памет. На истински сървър N = 2^17.
- Записът пази всичко нужно за проверка: алгоритъм $ цена $ salt $ hash. Самата парола я няма в него.
- При вход кодът чете цената и salt-а от записа и hash-ира въведената парола по същия начин.
- Двата hash-а се сравняват за постоянно време: „qwerty“ дава ✓, „qwerty1“ дава ✗. Еднаквите пароли вече не личат, а всяко предположение е бавно.
Последният кадър събира трите стъпки: колоната пази запис със salt и цена, еднакви редове няма, а входът проверява паролата по записа.
Идеята
Бавен hash и записът
Бавният hash за пароли повтаря работата много пъти. Колко пъти е цената (cost, work factor). Входът смята веднъж и чака част от секундата. Който има изтеклата таблица, плаща същата цена за всяко свое предположение.
Стандартната библиотека има scrypt: освен време иска и памет, а паметта е скъпа за видеокартите.const kdf = (password, salt, n) => scryptSync(password, salt, 32, { N: n });scryptSync взима паролата, salt-а, дължината в байтове и N (цената, степен на двойката). На истински сървър ползвай асинхронното crypto.scrypt: синхронното спира event loop-а за цялото смятане (виж урока за async).Записът пази всичко, което трябва за проверката: алгоритъм, цена, salt и hash, с $ между тях. Така цената може да се вдигне по-късно, без да се чупят старите записи.
// демо; на истински сървър 2 ** 17 (OWASP)const N = 2 ** 12;
function hashPassword(password) { const salt = randomBytes(16); const hash = kdf(password, salt, N); return ["scrypt", N, salt.toString("base64"), hash.toString("base64")].join("$");}
function verify(password, record) { const [, n, salt, expected] = record.split("$"); const actual = kdf(password, Buffer.from(salt, "base64"), Number(n)); const want = Buffer.from(expected, "base64"); return timingSafeEqual(actual, want);}const record = hashPassword("qwerty");const parts = record.split("$");console.log(`Алгоритъм и цена: ${parts[0]}, ${parts[1]}`);console.log("Вход с правилната парола:", verify("qwerty", record));console.log("Вход с грешна парола:", verify("qwerty1", record));console.log("Една парола, два записа, еднакви:", record === hashPassword("qwerty"));Алгоритъм и цена: scrypt, 4096Вход с правилната парола: trueВход с грешна парола: falseЕдна парола, два записа, еднакви: falseЦената в програмата е малка, за да върви бързо. На истински сървър OWASP препоръчва N = 2¹⁷ (r = 8, p = 1) за scrypt. В истински проект обикновено не пишеш това сам, а ползваш bcrypt или argon2id от проверена библиотека (подробно в „По-точно“).
Пробвай
Предвиди изхода
Една и съща парола, hash-ирана два пъти. Какво ще отпечата?
const first = hashPassword("qwerty");const second = hashPassword("qwerty");console.log(first === second);console.log(verify("qwerty", first));console.log(verify("qwerty", second));Всеки запис има свой случаен salt, затова двата записа са различни. Проверката чете salt-а от записа, затова „qwerty“ минава и при двата.
Пробвай
Попълни
При вход двата hash-а се сравняват. Обикновеното сравнение спира на първия различен байт, а по времето за отговор може да се познае докъде съвпадат. Кое сравнение минава винаги през всички байтове?
const want = Buffer.from(expected, "base64");
return (actual, want);timingSafeEqual от node:crypto сравнява всички байтове, независимо къде е първата разлика. Хвърля грешка, ако дължините са различни, затова двата буфера тук са по 32 байта.Пробвай
Намери грешката
Колега „опростява“ кода: вместо случаен salt за всеки ред, един salt за всички. Бавният hash е същият. Къде е проблемът?
Избери реда с грешката.
Един salt за всички редове е като никакъв salt: еднаквите пароли пак дават еднакви записи, а едно смятане пак важи за цялата таблица. Salt-ът трябва да е случаен и свой за всеки ред, създаден при всяко hashPassword.
Виж го
Цената расте с годините
Компютрите стават по-бързи, затова цената, която е стигала миналата година, след време не стига. Старите записи не могат да се смятат наново: hash-ът не се връща обратно, а паролата сървърът я има само в момента на входа. Затова новият hash става при успешен вход. Тук hashPassword получава цената като параметър, а записът пази своята, затова старите записи продължават да работят.
// миналата година беше 2 ** 11const N = 2 ** 12;
const costOf = (record) => Number(record.split("$")[1]);
function login(account, password) { if (!verify(password, account.record)) return false; if (costOf(account.record) < N) { // само сега паролата е в ръцете ни: hash с новата цена account.record = hashPassword(password, N); } return true;}const account = { record: hashPassword("qwerty", 2 ** 11) };console.log("Цена преди входа:", costOf(account.record));console.log("Вход:", login(account, "qwerty"));console.log("Цена след входа:", costOf(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 и bcrypt от npm (в Node ги няма). Те дават асинхронни hash и verify и пишат алгоритъма и цената в записа.
Pepper. Някои добавят и тайна стойност, обща за всички редове, която живее извън базата (в хранилище за тайни, виж урока за тайните). Изтичането само на базата тогава не стига. Помага, но не замества salt-а и бавния hash.
Политиката на паролите. Дълга парола (поне 12 знака) и проверка срещу списъци с изтекли пароли помагат повече от изисквания „главна буква и цифра“. Такава е и задачата по-долу.
Как е в другите езици: Java и C# имат PBKDF2 в стандартната библиотека, а scrypt нямат; Python и Node имат и двете (hashlib.pbkdf2_hmac, crypto.pbkdf2Sync). Никой от четирите няма argon2id или bcrypt вграден.
Упражнение
Смяна на паролата
Реши задачата в редактора си.
Сметката на купувача е с парола „qwerty“. Напиши changePassword(account, oldPassword, newPassword), която:
- проверява текущата парола с
verifyи отказва, ако е грешна; - отказва нова парола, по-къса от 12 знака;
- отказва нова парола, която (без значение от малки и главни букви) е в списъка
password1234,qwerty123456,123456789012; - иначе пази нов запис с нов salt и връща „Паролата е сменена“.
Извикай я с („qwerty1“, „new-pass-2026!“), („qwerty“, „short1“), („qwerty“, „Qwerty123456“) и („qwerty“, „tram-9-to-vitosha“), после провери входа с новата парола:
Грешна текуща паролаНовата парола е по-къса от 12 знакаТази парола е в списъците с изтекли паролиПаролата е смененаВход с новата парола: trueОще задачи
- Нов hash при вход. Сложи в задачата и
loginот „Цената расте с годините“ и провери, че стара сметка с по-ниска цена излиза от входа с новата. - Еднакво време и при грешен имейл. Ако имейлът го няма, входът отговаря веднага, а ако го има, смята бавния hash. Как би направил двата случая да траят еднакво?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Хеш код и хеш функции · Стрелкови функции · Методите на масива · Данни, switch и разглобяване
- Продължава в: Сесии, бисквитки и CSRF · JWT и капаните му · Криптографията, която не пишеш сам
- Задачи по темата: Сигурност за програмисти