Соль, перец и итерации: базовые концепции защиты

Хеширование паролей отличается от обычного вычисления хеша данных. Для защиты пользовательских паролей недостаточно применить быстрый алгоритм вроде SHA-256. Современная система хранения паролей должна противостоять:

  • атакам по радужным таблицам;
  • перебору словарей;
  • массовому GPU-брутфорсу;
  • утечке базы данных;
  • повторному использованию одинаковых паролей.

Библиотека Password-hash в JavaScript обычно строится вокруг трёх фундаментальных механизмов:

  • salt (соль);
  • pepper (перец);
  • iterations / cost factor (итерации).

Эти компоненты работают совместно и формируют основу безопасного хранения паролей.


Соль (Salt)

Что такое соль

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

Пример:

password = "qwerty123"
salt = "A91xK2Lm"

hash(password + salt)

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


Основная проблема без соли

Без соли одинаковые пароли создают одинаковые хеши.

Пример:

SHA256("123456") =
8d969eef6ecad3c29a3a629280e686cf...

Если в базе несколько одинаковых значений:

8d969eef6ecad3c29...
8d969eef6ecad3c29...
8d969eef6ecad3c29...

становится очевидно:

  • пользователи используют одинаковые пароли;
  • можно применять заранее подготовленные таблицы;
  • компрометация базы значительно упрощается.

Как соль защищает от радужных таблиц

Радужные таблицы

Радужная таблица — это заранее вычисленный набор:

пароль → хеш

Пример:

123456 → 8d969eef...
password → 5e884898...
qwerty → 65e84be3...

Если система использует чистый SHA-256 без соли, злоумышленник может мгновенно сравнить украденные хеши с готовой таблицей.


Соль разрушает предвычисления

С добавлением соли:

hash("123456" + "abc")
hash("123456" + "xyz")

результаты полностью различаются.

Теперь злоумышленнику пришлось бы строить отдельную радужную таблицу для каждой соли, что делает атаку практически бессмысленной.


Генерация соли в JavaScript

Использование crypto.randomBytes

Для генерации соли необходим криптографически стойкий генератор случайных чисел.

Node.js:

const crypto = require('crypto');

const salt = crypto.randomBytes(16).toString('hex');

console.log(salt);

Пример результата:

f8a91cc8b7d14d2e5a71d9a3e2bc9911

Почему Math.random() нельзя использовать

Ошибка:

const salt = Math.random().toString();

Проблемы:

  • предсказуемость;
  • недостаточная энтропия;
  • возможность восстановления последовательности;
  • небезопасность для криптографии.

Для паролей допускается только:

  • crypto.randomBytes;
  • Web Crypto API;
  • специализированные криптографические библиотеки.

Размер соли

Рекомендуемые размеры:

Размер Безопасность
8 байт Минимально допустимо
16 байт Стандарт
32 байта Повышенная защита

Чаще всего используется:

crypto.randomBytes(16)

Хранение соли

Соль не является секретом.

Допускается хранение:

  • рядом с хешем;
  • внутри итоговой строки bcrypt/argon2;
  • в отдельном поле базы данных.

Пример:

{
  "user": "alex",
  "salt": "f8a91cc8...",
  "hash": "8b2f3c1d..."
}

Проверка пароля с солью

При авторизации:

  1. Из базы извлекается соль.
  2. Введённый пароль объединяется с солью.
  3. Вычисляется новый хеш.
  4. Хеши сравниваются.

Пример:

const crypto = require('crypto');

function hashPassword(password, salt) {
    return crypto
        .createHash('sha256')
        .update(password + salt)
        .digest('hex');
}

Перец (Pepper)

Что такое pepper

Pepper — это дополнительный секретный ключ, общий для всей системы.

В отличие от соли:

Salt Pepper
уникален для пользователя общий для системы
хранится в БД хранится отдельно
не секретен секретен

Как работает pepper

Пример:

hash(password + salt + pepper)

Даже если база данных украдена, злоумышленник не сможет проверить пароли без знания pepper.


Где хранить pepper

Правильные места хранения:

  • переменные окружения;
  • secrets manager;
  • vault-хранилища;
  • HSM-модули.

Пример:

const pepper = process.env.PEPPER_SECRET;

Ошибка хранения pepper в базе

Неправильно:

{
  "salt": "...",
  "pepper": "...",
  "hash": "..."
}

Если злоумышленник получает базу полностью, защита исчезает.


Архитектура salt + pepper

Типичная схема:

hash(password + userSalt + globalPepper)

Где:

  • userSalt — уникальна;
  • globalPepper — секретен;
  • итоговый хеш — хранится в базе.

Итерации (Iterations)

Зачем нужны итерации

Современные видеокарты способны вычислять миллиарды SHA-хешей в секунду.

Простой SHA-256 слишком быстрый:

hash(password)

Это позволяет злоумышленнику быстро перебрать огромные словари.


Идея замедления

Парольное хеширование должно быть:

  • намеренно медленным;
  • дорогостоящим вычислительно;
  • устойчивым к параллельным атакам.

Именно поэтому используются:

  • bcrypt;
  • PBKDF2;
  • scrypt;
  • Argon2.

PBKDF2 и итерации

Принцип работы

PBKDF2 многократно повторяет вычисление хеша.

Упрощённо:

hash(hash(hash(hash(...))))

Тысячи или сотни тысяч раз.


Пример PBKDF2 в Node.js

const crypto = require('crypto');

crypto.pbkdf2(
    'password123',
    'randomSalt',
    100000,
    64,
    'sha512',
    (err, derivedKey) => {
        console.log(derivedKey.toString('hex'));
    }
);

Параметры PBKDF2

password

Исходный пароль:

'password123'

salt

Случайная соль:

'randomSalt'

iterations

Количество повторений:

100000

Чем больше значение:

  • тем медленнее вычисление;
  • тем сложнее brute-force.

keylen

Размер итогового ключа:

64

digest

Алгоритм хеширования:

'sha512'

Стоимость перебора

Без итераций

Допустим:

1 хеш = 0.000001 сек

Миллион паролей:

≈ 1 секунда

С итерациями

Если:

100000 итераций

тогда:

1 пароль ≈ 0.1 сек

Миллион попыток:

≈ 27 часов

На практике атака становится существенно дороже.


Cost factor в bcrypt

bcrypt использует параметр стоимости:

const bcrypt = require('bcrypt');

bcrypt.hash(password, 12);

Число 12 — это cost factor.


Экспоненциальный рост сложности

bcrypt увеличивает сложность по степени двойки.

Cost Относительная сложность
10 2^10
11 2^11
12 2^12
13 2^13

Каждое увеличение на 1 почти удваивает время вычисления.


Проверка bcrypt

const bcrypt = require('bcrypt');

const hash = await bcrypt.hash('password123', 12);

const match = await bcrypt.compare(
    'password123',
    hash
);

console.log(match);

bcrypt автоматически:

  • извлекает соль;
  • применяет cost factor;
  • сравнивает результат.

Argon2 и современный подход

Argon2 считается одним из наиболее защищённых алгоритмов.

Он учитывает:

  • время вычисления;
  • потребление памяти;
  • параллелизм.

Почему память важна

GPU хорошо выполняют параллельные вычисления, но память ограничена.

Argon2 делает атаку дорогой не только по CPU, но и по RAM.


Пример Argon2

const argon2 = require('argon2');

const hash = await argon2.hash('password123');

const valid = await argon2.verify(
    hash,
    'password123'
);

Виды Argon2

Вариант Назначение
Argon2d защита от GPU
Argon2i защита от side-channel
Argon2id комбинированный вариант

Обычно рекомендуется:

Argon2id

Комбинация salt, pepper и iterations

Полноценная схема:

hash(
    password +
    uniqueSalt +
    globalPepper
)
↓
100000+ iterations

или:

Argon2id(password, salt, memoryCost)

Типичные ошибки

Использование SHA-256 напрямую

Ошибка:

crypto.createHash('sha256')

Причина:

  • слишком быстро;
  • уязвимо к GPU;
  • плохо защищает от перебора.

Одинаковая соль для всех

Плохо:

const salt = "STATIC_SALT";

Соль обязана быть уникальной.


Короткая соль

Неправильно:

crypto.randomBytes(2)

2 байта легко перебираются.


Отсутствие pepper

Без pepper утечка базы опаснее.

Pepper создаёт дополнительный уровень защиты.


Слишком маленькое количество итераций

Плохо:

1000 iterations

Современные рекомендации значительно выше.


Рекомендуемые параметры

PBKDF2

iterations: 100000+
digest: SHA-512
salt: 16+ bytes

bcrypt

cost factor: 10–14

Подбирается по производительности сервера.


Argon2id

Рекомендуется:

  • высокая memory cost;
  • moderate time cost;
  • уникальная соль.

Практическая схема хранения

Пример:

{
  "id": 15,
  "email": "user@example.com",
  "passwordHash": "$argon2id$v=19$m=65536,t=3,p=4$..."
}

Современные алгоритмы часто включают внутрь:

  • соль;
  • параметры стоимости;
  • версию алгоритма.

Проверка пароля в реальной системе

Алгоритм:

1. Пользователь вводит пароль
2. Из БД извлекается hash
3. Алгоритм извлекает salt и параметры
4. Добавляется pepper
5. Выполняется повторное хеширование
6. Результат сравнивается

Почему нельзя расшифровать пароль

Парольные хеши не предназначены для расшифровки.

Хеширование:

password → hash

является односторонней функцией.

Проверка выполняется повторным вычислением:

hash(input) === storedHash

Эволюция защиты паролей

Старый подход

MD5(password)

или:

SHA1(password)

Сегодня такие схемы считаются небезопасными.


Современный подход

Argon2id
+ unique salt
+ secret pepper
+ memory hard
+ configurable cost

Именно такая архитектура применяется в современных безопасных системах аутентификации.