Хранение паролей

Пароли НИКОГДА не хранятся в открытом виде. Используются криптографические хеш-функции (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);

Практика

  1. Реализуй регистрацию и логин с bcrypt в Express-приложении
  2. Протестируй: создай пользователя, попробуй войти с правильным и неправильным паролем
  3. Замерь время хеширования при разных cost factor (10, 12, 14)
  4. Попробуй argon2 вместо bcrypt — сравни API
  5. Добавь валидацию пароля: минимум 8 символов, заглавная, цифра

Связанные темы

Ресурсы