Хранение паролей
Пароли НИКОГДА не хранятся в открытом виде. Используются криптографические хеш-функции (bcrypt, argon2) с солью для безопасного хранения.
Зачем нужно
Утечки баз данных случаются регулярно. Если пароли хранятся в plaintext — все пользователи скомпрометированы мгновенно. Правильное хеширование даёт время: даже при утечке хешей взлом каждого пароля займёт годы.
Где используется
Любое приложение с аутентификацией: веб-сайты, API, мобильные приложения, микросервисы. Также: хранение API-ключей, секретов, токенов.
Предпосылки
OWASP Top 10, базовое понимание криптографии
Почему нельзя хранить в plaintext
Утечка БД с паролями в plaintext:
users:
alice@mail.com | MySecret123
bob@mail.com | Password456
carol@mail.com | Qwerty789
Результат: 100% аккаунтов скомпрометированы мгновенно.
Многие используют одинаковые пароли на разных сайтах.
Хеширование vs Шифрование
| Свойство | Хеширование | Шифрование |
|---|---|---|
| Направление | Одностороннее | Двустороннее |
| Восстановление | Невозможно | Возможно с ключом |
| Для паролей | ДА | НЕТ |
| Для данных | Нет | ДА (данные карт и т.д.) |
| Пример | bcrypt, argon2 | AES-256 |
Хеширование:
"MySecret123" → "$2b$12$LJ3m4ys..." → ???
(пароль) (хеш) (нельзя восстановить)
Шифрование:
"Данные карты" → "x8f2k9..." → "Данные карты"
(данные) (шифрованное) (расшифрованное)
Почему обычные хеши (MD5, SHA) не подходят
// ПЛОХО: MD5 / SHA — слишком быстрые!
const crypto = require('crypto');
const hash = crypto.createHash('md5').update('password').digest('hex');
// '5f4dcc3b5aa765d61d8327deb882cf99'
// GPU может вычислять 10 МИЛЛИАРДОВ MD5 хешей в секунду!
// Все пароли из словаря взламываются за минуты.
| Алгоритм | Скорость GPU | Время на "password" |
|---|---|---|
| MD5 | ~10 млрд/с | мгновенно |
| SHA-256 | ~3 млрд/с | мгновенно |
| bcrypt (cost=12) | ~10/с | годы для сложных паролей |
| argon2 | ~3/с | десятилетия |
Соль (Salt)
Соль — случайные данные, добавляемые к паролю перед хешированием. Защищает от rainbow tables и одинаковых хешей.
Без соли:
"password" → "5f4dcc3b..." (одинаковый хеш у всех с "password")
С солью:
"password" + "x8kL9p" → "a1b2c3d4..."
"password" + "mN3qR7" → "e5f6g7h8..." (разные хеши!)
// bcrypt автоматически генерирует и хранит соль внутри хеша
const hash = await bcrypt.hash('password', 12);
// '$2b$12$LJ3m4ysEOQl/M5s.../...' ← содержит: алгоритм, cost, соль, хеш
// $2b$ 12$ LJ3m4ysEOQl... /хеш...
// версия cost соль хеш
bcrypt — стандарт индустрии
const bcrypt = require('bcrypt');
// Регистрация — хеширование пароля
async function register(email, password) {
const saltRounds = 12; // Cost factor: 2^12 итераций
const hash = await bcrypt.hash(password, saltRounds);
// Сохраняем hash в БД (НЕ password!)
await db.createUser({ email, passwordHash: hash });
}
// Логин — проверка пароля
async function login(email, password) {
const user = await db.findByEmail(email);
if (!user) throw new Error('Пользователь не найден');
// bcrypt сам извлекает соль из хеша и сравнивает
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) throw new Error('Неверный пароль');
return user;
}
Выбор cost factor
// Cost factor определяет количество итераций (2^cost)
// Чем больше — тем медленнее (и безопаснее)
// Тестирование скорости:
async function findOptimalCost() {
for (let cost = 8; cost <= 16; cost++) {
const start = Date.now();
await bcrypt.hash('test', cost);
const time = Date.now() - start;
console.log(`Cost ${cost}: ${time}ms`);
}
}
// Ориентир:
// Cost 10: ~100ms (минимум для production)
// Cost 12: ~300ms (хороший баланс)
// Cost 14: ~1200ms (высокая безопасность)
// Цель: хеширование занимает 250-500ms
argon2 — современная альтернатива
Argon2 — победитель Password Hashing Competition (2015). Использует не только CPU, но и ПАМЯТЬ, что затрудняет атаку на GPU.
const argon2 = require('argon2');
// Регистрация
async function register(email, password) {
const hash = await argon2.hash(password, {
type: argon2.argon2id, // Рекомендуемый вариант
memoryCost: 65536, // 64 MB памяти
timeCost: 3, // 3 итерации
parallelism: 4, // 4 потока
});
await db.createUser({ email, passwordHash: hash });
}
// Логин
async function login(email, password) {
const user = await db.findByEmail(email);
if (!user) throw new Error('Пользователь не найден');
const isValid = await argon2.verify(user.passwordHash, password);
if (!isValid) throw new Error('Неверный пароль');
return user;
}
bcrypt vs argon2
| Свойство | bcrypt | argon2 |
|---|---|---|
| Возраст | 1999 | 2015 |
| Защита от GPU | Хорошая | Отличная (memory-hard) |
| Настройка | cost factor | time, memory, parallelism |
| Поддержка | Везде | Растёт |
| Рекомендация | Проверенный стандарт | Современный выбор |
Полный пример: Express + bcrypt
const express = require('express');
const bcrypt = require('bcrypt');
const { body, validationResult } = require('express-validator');
const app = express;
app.use(express.json());
// Регистрация
app.post('/register',
body('email').isEmail.normalizeEmail,
body('password')
.isLength({ min: 8 })
.matches(/[A-Z]/).withMessage('Нужна заглавная буква')
.matches(/[0-9]/).withMessage('Нужна цифра'),
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty) {
return res.status(400).json({ errors: errors.array });
}
const { email, password } = req.body;
// Проверка дубликата
const existing = await db.findByEmail(email);
if (existing) {
return res.status(409).json({ error: 'Email уже зарегистрирован' });
}
// Хешируем пароль
const passwordHash = await bcrypt.hash(password, 12);
// Сохраняем
const user = await db.createUser({ email, passwordHash });
res.status(201).json({ id: user.id, email: user.email });
}
);
// Логин
app.post('/login',
body('email').isEmail.normalizeEmail,
body('password').notEmpty,
async (req, res) => {
const { email, password } = req.body;
const user = await db.findByEmail(email);
// Важно: одинаковое сообщение для "нет пользователя" и "неверный пароль"
if (!user) {
return res.status(401).json({ error: 'Неверные учётные данные' });
}
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
return res.status(401).json({ error: 'Неверные учётные данные' });
}
// Создаём сессию/JWT
const token = createToken(user);
res.json({ token });
}
);
Частые ошибки
1. Хранение в plaintext или Base64
// ПЛОХО:
await db.createUser({ email, password }); // Plaintext!
await db.createUser({ email, password: btoa(password) }); // Base64 — НЕ защита!
// ХОРОШО:
const hash = await bcrypt.hash(password, 12);
await db.createUser({ email, passwordHash: hash });
2. Использование MD5/SHA для паролей
// ПЛОХО: быстрые хеш-функции
const hash = crypto.createHash('sha256').update(password).digest('hex');
// ХОРОШО: специализированные функции для паролей
const hash = await bcrypt.hash(password, 12);
3. Разные сообщения об ошибках
// ПЛОХО: раскрывает, существует ли email
if (!user) res.json({ error: 'Пользователь не найден' });
if (!passwordMatch) res.json({ error: 'Неверный пароль' });
// ХОРОШО: одинаковое сообщение
res.json({ error: 'Неверные учётные данные' });
4. Собственная «криптография»
// ПЛОХО: изобретение велосипеда
function myHash(password) {
return password.split('').reverse().join('') + '123';
}
// ХОРОШО: проверенные библиотеки
const hash = await bcrypt.hash(password, 12);
Практика
- Реализуй регистрацию и логин с bcrypt в Express-приложении
- Протестируй: создай пользователя, попробуй войти с правильным и неправильным паролем
- Замерь время хеширования при разных cost factor (10, 12, 14)
- Попробуй argon2 вместо bcrypt — сравни API
- Добавь валидацию пароля: минимум 8 символов, заглавная, цифра