Node crypto без ошибок: KDF, HMAC, подписи и сравнение за константное время
Модуль crypto в Node корректен только при правильном выборе примитива: scrypt для паролей, HMAC для аутентифицированной целостности, randomBytes для секретов, timingSafeEqual для сравнения. Быстрые хэши и === — это баги.
Команда хранила пароли как sha256(password) — быстро, «захэшировано», на ревью выглядит нормально. Потом утёк бэкап. Поскольку SHA-256 спроектирован быть быстрым, GPU атакующего перебирал миллиарды догадок в секунду сразу по всем строкам, а отсутствие salt означало, что одна заранее посчитанная rainbow-таблица мгновенно вскрыла популярные пароли. Та же кодовая база сравнивала API-токены через ===, утекая тайминг, который позволял восстановить валидный токен байт за байтом. Каждый из этих случаев — это crypto с неправильным примитивом, а правильный был в одном вызове функции.
Хэш против HMAC: целостность, а затем аутентифицированная целостность
Обычный хэш отвечает на один вопрос: менялись ли эти байты? crypto.createHash('sha256') отображает вход в дайджест фиксированной длины, поэтому одинаковый вход всегда даёт одинаковый выход, и любая подмена его меняет. Это годится для контрольной суммы контента или ключа кэша — но ничего не доказывает о том, кто его произвёл. Дайджест может пересчитать кто угодно, поэтому атакующий, изменивший сообщение, просто пересчитает его хэш, и ты этого не отличишь.
HMAC закрывает этот пробел. Он подмешивает в хэш секретный ключ, поэтому валидный тег могут произвести или проверить только стороны, владеющие ключом. Используй обычный хэш для целостности против случайного повреждения; используй HMAC, когда нужна аутентифицированная целостность — доказательство, что значение пришло от того, кто знает ключ. Подписанные cookie, подписи webhook’ов (Stripe, GitHub) и stateless-токены опираются на HMAC, а не на голый хэш.
const crypto = require('node:crypto');
// Обычный хэш — только целостность, пересчитать может кто угодно
const digest = crypto.createHash('sha256').update(payload).digest('hex');
// HMAC — с ключом, валидный тег производят/проверяют только владельцы ключа
const key = process.env.WEBHOOK_SECRET;
const tag = crypto.createHmac('sha256', key).update(rawBody).digest('hex');
// позже: сравни `tag` со значением из заголовка через timingSafeEqual (ниже)Паролям нужен KDF, а не быстрый хэш
Баг из хука — самая частая crypto-ошибка в веб-приложениях: хэширование паролей быстрым алгоритмом. SHA-256, SHA-512 и MD5 спроектированы быть быстрыми, а это ровно то, чего для паролей хотеть не надо: скорость помогает атакующему перебирать офлайн. Утёкшая база несолёных SHA-256-дайджестов падает под GPU, делающими миллиарды догадок в секунду, а одинаковые пароли дают одинаковые дайджесты, поэтому работают rainbow-таблицы и атаки «взломай раз — подбери многим».
Хранению паролей нужна функция вывода ключа (KDF): намеренно медленная, memory-hard и солёная для каждого пароля. Node поставляет scrypt и pbkdf2 нативно; argon2 и bcrypt доступны через библиотеки. Salt (из CSPRNG) делает каждый хэш уникальным, так что одинаковые пароли различаются, а rainbow-таблицы умирают; cost factor делает каждую догадку дорогой. Всегда используй асинхронную форму, чтобы не блокировать event loop, пока KDF намеренно жжёт CPU.
const crypto = require('node:crypto');
function hashPassword(password) {
return new Promise((resolve, reject) => {
const salt = crypto.randomBytes(16); // уникальный на пароль, из CSPRNG
// cost N=2**15, выход 64 байта; salt хранится рядом с хэшем.
// maxmem нужно поднять: scrypt требует 128*N*r байт (здесь 32 МиБ), а
// дефолтный лимит maxmem — ровно 32 МиБ, поэтому любой реальный cost падает без него.
crypto.scrypt(password, salt, 64, { N: 2 ** 15, maxmem: 64 * 1024 * 1024 }, (err, hash) => {
if (err) return reject(err);
resolve(`${salt.toString('hex')}:${hash.toString('hex')}`);
});
});
}
function verifyPassword(password, stored) {
return new Promise((resolve, reject) => {
const [saltHex, hashHex] = stored.split(':');
const salt = Buffer.from(saltHex, 'hex');
const expected = Buffer.from(hashHex, 'hex');
crypto.scrypt(password, salt, expected.length, { N: 2 ** 15, maxmem: 64 * 1024 * 1024 }, (err, actual) => {
if (err) return reject(err);
resolve(crypto.timingSafeEqual(actual, expected)); // константное время, ниже
});
});
}
// использование — хэш при регистрации, проверка при логине (и смоук-тест, что cost-параметры исполняются)
const stored = await hashPassword('correct horse battery staple');
console.log('verify correct:', await verifyPassword('correct horse battery staple', stored)); // true
console.log('verify wrong: ', await verifyPassword('wrong', stored)); // false▸Почему это работает
Почему «быстро» — это враг? Обычный логин проверяет один пароль на запрос — микросекунды в любую сторону пользователю незаметны. Но атакующий с утёкшим дампом хэшей гоняет офлайн против твоих хранимых хэшей без rate limit и без сетевого round-trip. Если твой хэш быстрый — он тестирует миллиарды в секунду; если это настроенный scrypt с высоким N — тысячи. Cost factor — это ручка, которую ты крутишь вверх с годами по мере роста железа; асимметрия между одним честным логином и миллиардом догадок атакующего и есть весь смысл.
Асимметричные подписи: доказать происхождение без общего секрета
HMAC работает, когда обе стороны делят один секрет. Когда не могут — ты хочешь опубликовать нечто, что проверить может кто угодно, а произвести только ты (релиз ПО, JWT, подписанный сервером авторизации, лицензия) — нужна асимметричная подпись. Ты подписываешь приватным ключом, а проверяет любой соответствующим публичным. Публичный ключ не даёт никакой возможности подделать; подписать может только приватный.
const crypto = require('node:crypto');
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
// Подпись приватным ключом (Ed25519 принимает null как аргумент алгоритма)
const signature = crypto.sign(null, Buffer.from(message), privateKey);
// Любой с публичным ключом проверяет — возвращает true/false
const ok = crypto.verify(null, Buffer.from(message), publicKey, signature);Для потоковых или больших входов используй объектный API crypto.createSign('SHA256') / crypto.createVerify('SHA256') с ключами RSA или ECDSA: многократно .update(chunk), затем .sign(privateKey) / .verify(publicKey, sig). Выбор ключа — HMAC против подписи — про то, можно ли доверять проверяющим силу подписи.
Нужно дать многим третьим сторонам проверять, что сообщение пришло от твоего сервиса, при этом никто из них не должен мочь подделывать сообщения. Выбери примитив.
Случайность и сравнение за константное время: два тихих footgun’а
Ещё две ошибки коварны, потому что код работает нормально. Когда тянешься за Math.random(), чтобы сгенерировать токен, всё работает — пока не перестаёт. Первая — случайность: Math.random() — это быстрый PRNG (псевдослучайный генератор) без криптографических гарантий; его выход предсказуем и никогда не должен генерировать токены, salt, session ID или ключи. Используй crypto.randomBytes(n) (CSPRNG — криптографически стойкий генератор псевдослучайных чисел) для сырых байтов и crypto.randomUUID() для идентификаторов. Вторая — сравнение: сравнение секретов через ===, == или Buffer.compare выходит на первом отличающемся байте, поэтому затраченное время утекает, сколько ведущих байтов совпало — и атакующий восстанавливает токен байт за байтом, измеряя время ответа. Используй crypto.timingSafeEqual(a, b), который сравнивает за константное время. Он требует два Buffer равной длины (иначе бросает исключение), поэтому сначала приведи обе стороны к фиксированной длине хэшем или проверь длину перед вызовом.
const crypto = require('node:crypto');
// НЕВЕРНО — утекает через тайминг, сколько ведущих байтов совпало
if (userToken === storedToken) grantAccess();
// ВЕРНО — константное время; оба Buffer должны быть равной длины
const a = Buffer.from(userToken);
const b = Buffer.from(storedToken);
if (a.length === b.length && crypto.timingSafeEqual(a, b)) grantAccess();| Задача | Правильный примитив | Неверный выбор и почему он провален |
|---|---|---|
| Хранить пароль | crypto.scrypt / pbkdf2 (async, salt, высокий cost) | sha256/md5 — быстрый + без salt → GPU-перебор, rainbow-таблицы |
| Аутентифиц. целостность (общий ключ) | crypto.createHmac | голый createHash — без ключа, атакующий пересчитает дайджест |
| Проверяемое происхождение, недоверенные проверяющие | crypto.sign/verify (Ed25519/RSA) | HMAC — каждый проверяющий может и подделывать |
| Токен / salt / session ID | crypto.randomBytes / randomUUID | Math.random() — предсказуем, не CSPRNG |
| Сравнить два секрета | crypto.timingSafeEqual (Buffer равной длины) | ===/== — ранний выход утекает тайминг → восстановление по байту |
Сервис хранит пароли как crypto.createHash('sha256').update(password).digest('hex'). Почему это небезопасно?
Почему заменять `userToken === storedToken` на crypto.timingSafeEqual при сравнении секрета?
Расставь шаги безопасной проверки присланного пароля против хранимого scrypt-хэша:
- 1 Разбей хранимое значение на salt и ожидаемый хэш
- 2 Перезапусти scrypt на присланном пароле с этим salt и тем же cost factor
- 3 Убедись, что выведенный и ожидаемый хэш — Buffer равной длины
- 4 Сравни их через crypto.timingSafeEqual (константное время)
- 5 Дай или отклони доступ по булеву результату
- 01Почему хранить пароли через sha256 небезопасно и что использовать вместо?
- 02Почему сравнение секрета через === утекает информацию и как timingSafeEqual это чинит?
Модуль crypto в Node настолько безопасен, насколько верен твой выбор примитива. Обычный хэш (createHash) даёт целостность, но не аутентичность — пересчитать может кто угодно — поэтому тянись к HMAC (createHmac), когда нужно доказать, что значение пришло от владельца ключа, как в подписях webhook’ов и подписанных cookie. Пароли — отдельная категория: никогда не быстрый хэш вроде SHA-256 или MD5, которые падают под офлайн GPU-перебор и rainbow-таблицы, а намеренно медленный, солёный KDF — scrypt или pbkdf2 в асинхронной форме, с salt на каждый пароль из CSPRNG и cost factor, который ты поднимаешь со временем. Когда проверяющим нельзя доверить силу подписи, переходи с HMAC на асимметричные подписи (crypto.sign/verify или createSign/createVerify с парой ключей Ed25519 или RSA): подписывай приватным ключом, дай любому проверять публичным. Генерируй каждый секрет, salt и ID через crypto.randomBytes или crypto.randomUUID, никогда Math.random, который не CSPRNG. И сравнивай любые два секрета через crypto.timingSafeEqual на Buffer равной длины, никогда === или ==, потому что сравнение с ранним выходом утекает тайминг, позволяющий восстановить секрет байт за байтом. Правильный примитив, правильный вызов — неверный учит привычке, которая тихо отгружает уязвимость. Теперь, когда встретишь sha256(password) или === при сравнении токена в пул-реквесте, ты знаешь ровно две строки, которые нужно пометить, и почему каждая ломается под атакой.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.