Як використовувати модуль Crypto в Node.js для хешування та шифрування?
Модуль crypto у Node.js дає хешування та шифрування через OpenSSL без жодних зовнішніх пакетів. Він вбудований у Node.js і покриває все: від односторонніх дайджестів до AES-шифрування і генерації безпечних токенів.
Теорія
TL;DR
- Хешування (hashing) незворотне: дані заходять, виходить фіксований відбиток, назад дороги немає
- Шифрування (encryption) оборотне: той самий ключ плюс IV замикає і відкриває дані
- Аналогія: хешування - це м'ясорубка (стейк заходить, фарш виходить, стейк не відновиш); шифрування - це сейф (той самий ключ відчиняє)
- Паролі завжди хешуй через PBKDF2 або scrypt, ніколи не шифруй
crypto.randomBytes()- єдине правильне джерело випадкових значень для безпеки в Node.js
Короткий приклад
const crypto = require('crypto');
// Односторонній хеш - тільки порівнювати, не декодувати
const hash = crypto.createHash('sha256')
.update('myPassword123')
.digest('hex');
// 8d969eef6ecad3c29a3a629280e75283e6e8a794ea1949be2d6204e551b41d5a
// Оборотне шифрування AES-256-CBC
const key = crypto.randomBytes(32); // 256-бітний ключ
const iv = crypto.randomBytes(16); // свіжий IV щоразу
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update('sensitive data', 'utf8', 'hex');
encrypted += cipher.final('hex');
// Розшифрувати: createDecipheriv('aes-256-cbc', key, iv)createHash повертає дайджест, який можна тільки порівняти. createCipheriv повертає дані, які можна відновити, якщо тримаєш ключ і IV.
Головна різниця
Хешування - одностороннє. Зберігаєш дайджест, оригінал викидаєш, потім перевіряєш хешуванням вхідного значення і порівнянням. Для паролів підходить ідеально: тобі ніколи не потрібно знати оригінал. Шифрування - двостороннє. З правильним ключем можна отримати оригінал назад. Це потрібно для токенів або даних сесії, які треба прочитати пізніше. Правило просте: хешуй те, що треба лише перевіряти; шифруй те, що треба читати назад.
Коли що використовувати
- Зберігання паролів:
crypto.pbkdf2()абоcrypto.scrypt()з випадковою 16-байтовою сіллю, зберігайsalt:hash - Цілісність даних / контрольні суми:
createHash('sha256') - API-токени / дані сесії: AES-256-GCM з ключем із env-змінної
- Перевірка підписів вебхуків: HMAC-SHA256 із
timingSafeEqual() - Безпечні випадкові токени:
crypto.randomBytes(32).toString('hex') - Унікальні ідентифікатори:
crypto.randomUUID()
MD5 і SHA-1 для паролів не підходять. Обидва алгоритми спроектовані швидкими, а швидкість для паролів - це проблема. Сучасна GPU видає близько 10 мільярдів MD5-хешів за секунду.
Хешування паролів докладніше
Простий createHash('sha256') на паролі має одну серйозну проблему: два користувачі з однаковим паролем отримують однаковий хеш. Зловмисник з rainbow table (таблицею передобчислених хешів) зламає обох одним запитом. Рішення - випадкова сіль (salt), що зберігається разом із хешем.
PBKDF2 навмисно запускає HMAC тисячі разів. 310 000 ітерацій - поточна рекомендація OWASP для SHA-512. Це робить кожну спробу дорогою. crypto.scrypt() (доступний з Node 12) іде далі: він memory-hard, тобто потребує великий блок пам'яті на кожну спробу, що робить атаки через GPU та ASIC значно складнішими.
AES-шифрування на практиці
AES потребує ключ (key) і вектор ініціалізації (IV). Ключ секретний. IV секретним не є, але має бути унікальним для кожного виклику шифрування. Якщо повторити IV з тим самим ключем, зловмисник може XOR-нути два шифротексти і витягти інформацію про обидва повідомлення.
AES-256-GCM краще за AES-256-CBC для більшості задач. GCM робить шифрування і автентифікацію за один прохід і видає 16-байтовий auth tag. CBC тільки шифрує. Якщо хтось змінить біт у шифротексті, CBC при розшифруванні видасть неправильний результат без жодної помилки. GCM кине виняток. Я бачив команди, які кілька днів шукали причину пошкоджених даних сесії, поки не зрозуміли, що використовують CBC без перевірки цілісності.
HMAC для автентифікації повідомлень
createHmac - це createHash плюс секретний ключ. Простий createHash вразливий до атак на подовження рядка (length extension), якщо використовувати його як MAC. createHmac цієї проблеми не має. GitHub webhooks, Stripe event verification та більшість сучасних систем вебхуків використовують HMAC-SHA256 саме тому.
Завжди використовуй crypto.timingSafeEqual() при порівнянні HMAC. Звичайне === зупиняється на першому невідповідному байті. Зловмисник може виміряти різницю у часі відповіді і відновити очікуване значення байт за байтом.
Таблиця порівняння
| Характеристика | Хешування (SHA-256 / PBKDF2) | Шифрування (AES-256-GCM) |
|---|---|---|
| Оборотне? | Ні | Так, з ключем + IV |
| Розмір виходу | Фіксований (64 hex-символи для SHA-256) | Розмір входу + невеликий overhead |
| Потребує сіль / IV? | Сіль для паролів | IV завжди, свіжий щоразу |
| Вбудована автентифікація? | Ні | Так (16-байтовий GCM tag) |
| Швидкість | Швидко (PBKDF2 навмисно повільний) | Швидко з AES-NI |
| Сфера використання | Паролі, контрольні суми | Токени, секрети, дані сесії |
Як це працює всередині Node.js
Модуль crypto в Node.js підключає OpenSSL через нативний C++ шар. Хешування викликає реалізацію SHA з OpenSSL безпосередньо. PBKDF2 і scrypt виконуються в thread pool libuv, тому не блокують event loop. AES отримує апаратне прискорення через інструкції AES-NI на процесорах Intel та AMD. crypto.randomBytes() читає з CSPRNG операційної системи (/dev/urandom на Linux, CryptGenRandom на Windows). Тому Math.random() заборонений для будь-чого пов'язаного з безпекою: він детермінований і може бути передбачений.
Типові помилки
Помилка 1: Хешування паролів без солі
// Однаковий хеш для кожного користувача з паролем "password123"
crypto.createHash('sha256').update('password123').digest('hex');Bez випадкової солі один запит до rainbow table відкриває всіх користувачів з однаковим паролем. Рішення: зберігай salt:hash, де сіль - crypto.randomBytes(16).toString('hex').
Помилка 2: pbkdf2Sync в основному потоці
// Блокує event loop на 100ms+ при кожному логіні
crypto.pbkdf2Sync(password, salt, 310000, 64, 'sha512');При будь-якому навантаженні всі запити стають у чергу за кожним обчисленням. Використовуй асинхронний crypto.pbkdf2() з callback-ом. При 100 одночасних логінах синхронна версія перетворюється на дірку для DoS-атаки.
Помилка 3: Повторне використання того самого IV
const iv = Buffer.alloc(16, 0); // фіксований нульовий IV - так не можнаПовторний IV з тим самим ключем ламає конфіденційність AES. Генеруй crypto.randomBytes(16) перед кожним шифруванням і зберігай IV разом із шифротекстом.
Помилка 4: createCipher замість createCipheriv
createCipher виводить слабкий IV з ключа і є застарілим з Node 10. Завжди використовуй createCipheriv з явним випадковим IV.
Помилка 5: === для порівняння хешів
if (receivedHmac === expectedHmac) { ... } // timing attackПорівняння рядків зупиняється на першому розбіжному байті. Зловмисник вимірює час відповіді і відновлює очікуване значення. Використовуй crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected)).
Де зустрічається в реальних проектах
- Express + passport-local: PBKDF2 для зберігання і перевірки локальних паролів
- Next.js: AES-GCM для шифрування сесійних cookies типу
__Host- - Socket.io: HMAC підписує auth-токени на сервері перед відправкою клієнтам
- AWS SDK: SHA-256 для перевірки ETag при multipart-завантаженні в S3
- GitHub webhooks: HMAC-SHA256 підпис тіла запиту в заголовку
x-hub-signature-256
Follow-up питання
Q: Яка різниця між createHash і createHmac?
A: createHash - простий дайджест без ключа, вразливий до атак на подовження рядка. createHmac загортає хеш у HMAC(key, message), для якого потрібен секрет. Для автентифікації - createHmac, для контрольних сум - createHash.
Q: Навіщо PBKDF2 замість звичайного SHA-256 для паролів?
A: SHA-256 спроектований швидким, а значить мільярди спроб за секунду на GPU. PBKDF2 навмисно запускає хеш 310 000+ разів, щоб зробити кожну спробу повільною. scrypt ще кращий, бо він ще й memory-hard.
Q: AES-256-CBC проти AES-256-GCM?
A: CBC тільки шифрує і не перевіряє, чи шифротекст не змінили. Якщо змінили - отримаєш неправильний результат без помилки. GCM додає 16-байтовий auth tag і кине виняток при підробці. Для нового коду вибирай GCM.
Q: Як згенерувати криптографічно безпечний токен?
A: crypto.randomBytes(32).toString('hex'). Це 64 hex-символи з CSPRNG операційної системи. Math.random() для токенів безпеки не підходить.
Q (senior): Потокове шифрування великого файлу завалилося по пам'яті. Чому і як виправити?
A: Весь зашифрований результат буферизувався в пам'яті перед записом. Рішення - Node streams: fs.createReadStream('file').pipe(crypto.createCipheriv(...)).pipe(fs.createWriteStream('file.enc')). Чанки проходять через OpenSSL, не завантажуючи файл цілком. Генеруй свіжий IV для кожного файлу і дописуй його на початок вихідного файлу, щоб розшифрування могло його прочитати.
Приклади
Асинхронне хешування та перевірка пароля
const crypto = require('crypto');
async function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex');
return new Promise((resolve, reject) => {
// 310 000 ітерацій згідно з рекомендацією OWASP для sha512
crypto.pbkdf2(password, salt, 310000, 64, 'sha512', (err, key) => {
if (err) return reject(err);
resolve(`${salt}:${key.toString('hex')}`);
});
});
}
async function verifyPassword(password, stored) {
const [salt, hash] = stored.split(':');
return new Promise((resolve, reject) => {
crypto.pbkdf2(password, salt, 310000, 64, 'sha512', (err, key) => {
if (err) return reject(err);
// timingSafeEqual запобігає timing-атакам
resolve(crypto.timingSafeEqual(Buffer.from(hash, 'hex'), key));
});
});
}
const stored = await hashPassword('hunter2');
console.log(await verifyPassword('hunter2', stored)); // true
console.log(await verifyPassword('wrong', stored)); // falseАсинхронна версія не блокує event loop. На сервері зі 100 одночасними логінами синхронна версія ставить усі запити в чергу за кожним обчисленням по 100ms.
AES-256-GCM шифрування даних сесії
const crypto = require('crypto');
// Генерувати командою: openssl rand -hex 32
const SECRET_KEY = Buffer.from(process.env.SESSION_KEY, 'hex'); // 32 байти
function encryptSession(data) {
const iv = crypto.randomBytes(16); // унікальний щоразу
const cipher = crypto.createCipheriv('aes-256-gcm', SECRET_KEY, iv);
let ciphertext = cipher.update(JSON.stringify(data), 'utf8', 'hex');
ciphertext += cipher.final('hex');
const tag = cipher.getAuthTag().toString('hex'); // 16-байтовий тег цілісності
return `${iv.toString('hex')}:${ciphertext}:${tag}`;
}
function decryptSession(token) {
const [ivHex, ciphertext, tagHex] = token.split(':');
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
SECRET_KEY,
Buffer.from(ivHex, 'hex')
);
decipher.setAuthTag(Buffer.from(tagHex, 'hex')); // кине виняток при підробці
let plaintext = decipher.update(ciphertext, 'hex', 'utf8');
plaintext += decipher.final('utf8');
return JSON.parse(plaintext);
}
const token = encryptSession({ userId: 42, role: 'admin' });
console.log(decryptSession(token)); // { userId: 42, role: 'admin' }Формат токена: iv:ciphertext:authTag. Якщо щось у шифротексті зміниться між шифруванням і розшифруванням, decipher.final() кине виняток ще до того, як ти спробуєш розпарсити payload.
Перевірка підпису вебхука (HMAC)
const crypto = require('crypto');
const express = require('express');
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
function verifyWebhook(rawBody, signatureHeader) {
const expected = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
if (expected.length !== signatureHeader.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}
// express.raw() обов'язковий - підписуються сирі байти, не розпарсений JSON
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-hub-signature-256'];
if (!sig || !verifyWebhook(req.body, sig)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body);
// обробити подію...
res.sendStatus(200);
});Типова помилка: express.json() замість express.raw(). Якщо тіло спочатку розпарсити, ти хешуєш повторно серіалізований JSON-об'єкт, а не оригінальні байти, які підписував відправник. Підписи ніколи не співпадуть.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.