Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як використовувати модуль Crypto в Node.js для хешування та шифрування?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)Модуль **`crypto`** у Node.js надає хешування та шифрування через OpenSSL без зовнішніх пакетів. Для незворотного хешу використовуй `createHash`. Для паролів - `pbkdf2` або `scrypt` з сіллю. Для оборотного шифрування - `createCipheriv` з AES-256-GCM. ```js const hash = crypto.createHash('sha256').update('data').digest('hex'); const key = crypto.randomBytes(32); // 256-бітний ключ, зберігай в env const iv = crypto.randomBytes(16); // свіжий IV щоразу ``` **Ключове:** паролі хешуй (перевіряти достатньо), секрети, які треба прочитати назад, шифруй.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)ЗображенняМодуль **`crypto`** у Node.js дає хешування та шифрування через OpenSSL без жодних зовнішніх пакетів. Він вбудований у Node.js і покриває все: від односторонніх дайджестів до AES-шифрування і генерації безпечних токенів. ## Теорія ### TL;DR - Хешування (hashing) незворотне: дані заходять, виходить фіксований відбиток, назад дороги немає - Шифрування (encryption) оборотне: той самий ключ плюс IV замикає і відкриває дані - Аналогія: хешування - це м'ясорубка (стейк заходить, фарш виходить, стейк не відновиш); шифрування - це сейф (той самий ключ відчиняє) - Паролі завжди хешуй через PBKDF2 або scrypt, ніколи не шифруй - `crypto.randomBytes()` - єдине правильне джерело випадкових значень для безпеки в Node.js ### Короткий приклад ```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: Хешування паролів без солі** ```js // Однаковий хеш для кожного користувача з паролем "password123" crypto.createHash('sha256').update('password123').digest('hex'); ``` Bez випадкової солі один запит до rainbow table відкриває всіх користувачів з однаковим паролем. Рішення: зберігай `salt:hash`, де сіль - `crypto.randomBytes(16).toString('hex')`. **Помилка 2: `pbkdf2Sync` в основному потоці** ```js // Блокує event loop на 100ms+ при кожному логіні crypto.pbkdf2Sync(password, salt, 310000, 64, 'sha512'); ``` При будь-якому навантаженні всі запити стають у чергу за кожним обчисленням. Використовуй асинхронний `crypto.pbkdf2()` з callback-ом. При 100 одночасних логінах синхронна версія перетворюється на дірку для DoS-атаки. **Помилка 3: Повторне використання того самого IV** ```js const iv = Buffer.alloc(16, 0); // фіксований нульовий IV - так не можна ``` Повторний IV з тим самим ключем ламає конфіденційність AES. Генеруй `crypto.randomBytes(16)` перед кожним шифруванням і зберігай IV разом із шифротекстом. **Помилка 4: `createCipher` замість `createCipheriv`** `createCipher` виводить слабкий IV з ключа і є застарілим з Node 10. Завжди використовуй `createCipheriv` з явним випадковим IV. **Помилка 5: `===` для порівняння хешів** ```js 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 для кожного файлу і дописуй його на початок вихідного файлу, щоб розшифрування могло його прочитати. ## Приклади ### Асинхронне хешування та перевірка пароля ```js 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 шифрування даних сесії ```js 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) ```js 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-об'єкт, а не оригінальні байти, які підписував відправник. Підписи ніколи не співпадуть.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.