Модуль crypto в Node.js: хеши, соль, шифрование
Содержание
О проверке кода. Примеры не запускались — в отличие от материалов с бейджем. Код дан по документации Node.js. Говорим прямо, а не ставим бейдж «проверено».
crypto — встроенный модуль для криптографии. Тема, где ошибка стоит дорого, а половина проблем — от путаницы двух понятий. С них и начнём.
Хеш против шифрования
Самое важное различие во всей криптографии для разработчика:
Хеширование
Необратимо — только вперёд
- Из хеша нельзя восстановить данные
- Одинаковый вход → одинаковый хеш
- Для паролей, проверки целостности
- SHA-256, scrypt, bcrypt
Шифрование
Обратимо — с ключом назад
- С ключом данные расшифровываются
- Для секретов, которые надо прочитать позже
- Токены, файлы, сообщения
- AES-GCM
Хеш — улица с односторонним движением: из результата исходные данные не восстановить. Поэтому им проверяют пароли (сравнивают хеши, а не сами пароли) и целостность файлов.
Шифрование обратимо: зашифровали ключом — расшифровали тем же (или парным) ключом. Им защищают данные, которые нужно прочитать позже.
Грубая ошибка — «зашифровать пароль». Пароли хешируют, а не шифруют: их не нужно восстанавливать, нужно только проверять.
Хеширование данных (не паролей)
Для контрольных сумм и целостности — быстрые хеши подходят:
import { createHash } from 'node:crypto';
const hash = createHash('sha256')
.update('содержимое файла')
.digest('hex');
// "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
SHA-256 здесь на месте: проверить, не изменился ли файл, сверить контрольную сумму загрузки. Тот же приём мы видели в проверке целостности после сжатия.
Пароли: медленно и с солью
А вот для паролей SHA-256 категорически не годится — и это контринтуитивно. Проблема именно в скорости: современная видеокарта считает миллиарды SHA-256 в секунду, поэтому украденную базу быстрых хешей перебирают по словарю за часы.
Пароли хешируют нарочно медленными алгоритмами с солью:
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scryptAsync = promisify(scrypt);
// при регистрации
async function hashPassword(password) {
const salt = randomBytes(16); // своя соль на пользователя
const hash = await scryptAsync(password, salt, 64); // МЕДЛЕННО — это хорошо
return `${salt.toString('hex')}:${hash.toString('hex')}`;
}
// при входе
async function verify(password, stored) {
const [saltHex, hashHex] = stored.split(':');
const salt = Buffer.from(saltHex, 'hex');
const hash = await scryptAsync(password, salt, 64);
return timingSafeEqual(hash, Buffer.from(hashHex, 'hex'));
}
Три обязательные детали:
scrypt(встроен в Node) специально медленный — это защита, а не недостаток. Альтернативы:bcrypt,argon2(пакеты);- соль (
randomBytes) — своя у каждого пользователя, хранится рядом с хешем. Она делает одинаковые пароли разными в базе и обесценивает заранее посчитанные «радужные таблицы»; timingSafeEqualвместо===— сравнение за постоянное время, чтобы по скорости ответа нельзя было угадывать хеш по байту.
Случайность: только crypto
import { randomBytes, randomUUID } from 'node:crypto';
randomBytes(32).toString('hex'); // случайный токен (64 hex-символа)
randomUUID(); // "3d6f0e9c-..." — случайный UUID
Math.random() для безопасности использовать нельзя. Он быстрый и удобный, но предсказуемый: по нескольким значениям вычисляется вся последовательность. Токен сессии, код сброса пароля, ключ — только через crypto, который даёт криптографически стойкую случайность.
Правило простое: если случайное значение хоть как-то связано с безопасностью — crypto.randomBytes или crypto.randomUUID, никогда Math.random.
HMAC: подпись с секретом
Обычный хеш подтверждает, что данные не изменились. HMAC добавляет секретный ключ и подтверждает ещё и кто их отправил:
import { createHmac } from 'node:crypto';
const signature = createHmac('sha256', SECRET_KEY)
.update(payload)
.digest('hex');
Классическое применение — проверка вебхуков: платёжный сервис подписывает уведомление HMAC-ом с вашим общим секретом, вы пересчитываете подпись и сверяете. Совпало — сообщение настоящее и не подделано. На том же принципе работают JWT-токены.
Шифрование: AES-GCM
Когда данные нужно именно защитить и прочитать позже:
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const key = randomBytes(32); // 256-битный ключ
const iv = randomBytes(12); // вектор инициализации — свой на каждое шифрование
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update('секрет', 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag(); // подтверждает, что шифртекст не изменён
Практический совет: не изобретайте криптографию. AES-GCM — правильный современный выбор (шифрует и проверяет целостность разом), но детали (уникальный IV на каждое сообщение, хранение ключа вне кода — в переменных окружения) критичны. Для большинства задач лучше взять проверенную библиотеку или готовый сервис, чем собирать схему вручную.
Смежные темы: Buffer — crypto работает с байтами; переменные окружения — где хранить ключи; JWT-аутентификация — HMAC на практике. Полный список — в справочнике по Node.js.
Частые вопросы
Чем хеширование отличается от шифрования?
Можно ли хешировать пароль через SHA-256?
Зачем нужна соль?
Как сгенерировать безопасный случайный токен?
crypto.randomBytes(32).toString('hex') или crypto.randomUUID(). Обычный Math.random() для токенов, паролей и ключей использовать нельзя — он предсказуем. crypto даёт криптографически стойкую случайность.