Перейти к содержимому

2. Хэширование паролей

Почему нельзя хранить пароли в открытом виде?

Заголовок раздела «Почему нельзя хранить пароли в открытом виде?»

Если база данных утечёт, все пароли сразу доступны злоумышленнику. И пользователи часто используют один пароль на многих сайтах.

// НИКОГДА так не делай!
const user = {
password: 'mysecretpassword' // открытый текст — катастрофа
};

Хэш — это результат односторонней функции. Из хэша нельзя получить исходные данные обратно.

"password123" → bcrypt → "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"

Свойства хорошей хэш-функции для паролей:

  • Однонаправленная: из хэша нельзя восстановить пароль
  • Медленная: нельзя перебрать миллиарды вариантов
  • Уникальная (с солью): одинаковые пароли дают разные хэши

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

// Без соли — одинаковые пароли = одинаковые хэши
hash("password") → "abc123"
hash("password") → "abc123" // легко увидеть совпадение
// С солью — разные хэши даже для одинаковых паролей
hash("password" + "xK9p2mN1") → "def456"
hash("password" + "qL3r7sT8") → "ghi789"

Соль не секретна — она хранится вместе с хэшем. Её задача — сделать rainbow table атаки бесполезными.

bcrypt — самый популярный алгоритм хэширования паролей. Специально спроектирован медленным.

Окно терминала
npm install bcryptjs
# или нативная версия (быстрее):
npm install bcrypt
import bcrypt from 'bcryptjs';
async function registerUser(email, password) {
// cost factor (rounds) — чем выше, тем медленнее и безопаснее
// рекомендуется 10-12 для production
const saltRounds = 12;
// bcrypt сам генерирует соль и хэширует
const hashedPassword = await bcrypt.hash(password, saltRounds);
// Сохраняем хэш, НЕ оригинальный пароль
await db.users.create({
email,
password: hashedPassword
});
console.log('User created!');
}
async function loginUser(email, password) {
const user = await db.users.findOne({ email });
if (!user) {
// Важно: возвращать одинаковую ошибку для
// "пользователь не найден" и "неверный пароль"
// чтобы нельзя было перечислять пользователей
throw new Error('Invalid credentials');
}
// bcrypt.compare безопасно сравнивает пароль с хэшем
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
throw new Error('Invalid credentials');
}
return user;
}
$2b$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
│ │ │ │
│ │ │ └── хэш (31 символ)
│ │ └────────────────────── соль (22 символа)
│ └───────────────────────── cost factor (12)
└───────────────────────────── версия алгоритма (2b)
import bcrypt from 'bcryptjs';
import { performance } from 'perf_hooks';
async function benchmark() {
const password = 'testpassword';
for (const rounds of [8, 10, 12, 14]) {
const start = performance.now();
await bcrypt.hash(password, rounds);
const time = performance.now() - start;
console.log(`rounds=${rounds}: ${time.toFixed(0)}ms`);
}
}
benchmark();
// rounds=8: ~50ms
// rounds=10: ~200ms
// rounds=12: ~800ms
// rounds=14: ~3000ms

Выбирай rounds так, чтобы хэширование занимало 100-300мс на твоём сервере.

Argon2 победил в Password Hashing Competition (PHC) 2015 года. Лучше bcrypt по нескольким параметрам.

Три варианта:

  • Argon2d — защита от GPU атак (криптовалюты)
  • Argon2i — защита от side-channel атак (пароли)
  • Argon2id — гибрид, рекомендуется для паролей
Окно терминала
npm install argon2
import argon2 from 'argon2';
// Хэширование
async function hashPassword(password) {
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 2 ** 16, // 64 MB памяти
timeCost: 3, // 3 итерации
parallelism: 1, // 1 поток
});
return hash;
}
// Проверка
async function verifyPassword(hash, password) {
return await argon2.verify(hash, password);
}
// Использование
const hash = await hashPassword('mypassword');
const valid = await verifyPassword(hash, 'mypassword');
console.log(valid); // true

PBKDF2 доступен из коробки в Node.js через crypto:

import { randomBytes, pbkdf2 } from 'crypto';
import { promisify } from 'util';
const pbkdf2Async = promisify(pbkdf2);
async function hashPassword(password) {
const salt = randomBytes(32).toString('hex');
const iterations = 100000; // минимум 100k для production
const keyLen = 64;
const digest = 'sha512';
const hash = await pbkdf2Async(password, salt, iterations, keyLen, digest);
return `${salt}:${iterations}:${hash.toString('hex')}`;
}
async function verifyPassword(storedHash, password) {
const [salt, iterations, hash] = storedHash.split(':');
const newHash = await pbkdf2Async(
password,
salt,
parseInt(iterations),
64,
'sha512'
);
// Используем timingSafeEqual для защиты от timing attacks
const { timingSafeEqual } = await import('crypto');
return timingSafeEqual(
Buffer.from(hash, 'hex'),
newHash
);
}
// НИКОГДА так не делай!
const hash = crypto.createHash('md5').update(password).digest('hex');
// MD5 слишком быстрый — 10 миллиардов хэшей в секунду на GPU!
// Опасно: можно измерить время сравнения и угадать хэш
if (userHash === storedHash) { ... }
// Безопасно: constant-time comparison
const { timingSafeEqual } = require('crypto');
if (timingSafeEqual(Buffer.from(userHash), Buffer.from(storedHash))) { ... }
// В bcrypt это уже встроено в bcrypt.compare()
// Опасно!
console.log(`User ${email} logged in with password: ${password}`);
// Никогда не логируй пароли, токены и другие секреты!
console.log(`User ${email} logged in successfully`);

Как обновить хэши без сброса паролей:

async function loginWithRehash(email, password) {
const user = await db.users.findOne({ email });
if (!user) throw new Error('Invalid credentials');
let isValid = false;
// Пробуем старый алгоритм (PBKDF2)
if (user.hashVersion === 'v1') {
isValid = await verifyPbkdf2(user.password, password);
// При успехе — перехэшируем в bcrypt
if (isValid) {
const newHash = await bcrypt.hash(password, 12);
await db.users.update(user.id, {
password: newHash,
hashVersion: 'v2'
});
}
} else {
isValid = await bcrypt.compare(password, user.password);
}
if (!isValid) throw new Error('Invalid credentials');
return user;
}
  1. Создай функцию регистрации и входа с bcrypt (cost factor 12)
  2. Сравни время выполнения bcrypt rounds 8, 10, 12, 14
  3. Реализуй механизм перехэширования при входе
  4. Добавь ограничение попыток входа (rate limiting)
АлгоритмИспользованиеСтатус
MD5/SHA1НикогдаУстарел, небезопасен
SHA-256Только для проверки целостностиНе для паролей
PBKDF2Legacy, Node.js built-inПриемлем
bcryptПаролиРекомендуется
Argon2idПаролиЛучший выбор