Crypto, ошибки, graceful shutdown
Этот урок про границу процесса с внешним миром: пароли (как хранить), ошибки (как распространять), сигналы (как корректно умирать). Эти три темы стоят рядом, потому что в продакшне они почти всегда ломаются вместе: пароль попал в логи через ошибку, которая прилетела в uncaughtException и не дала шанса корректно закрыть соединение с БД.
1. node:crypto: что внутри
node:crypto, обёртка над OpenSSL. Что доступно из коробки:
- Случайность.
randomBytes,randomUUID,randomInt,webcrypto. - Хеши.
createHash(algo)(SHA-256, SHA-512, BLAKE2),createHmac. - Симметричное шифрование.
createCipheriv, AES-GCM как дефолт. - Асимметричное.
generateKeyPair, RSA/ECDSA/Ed25519, JWT-операции. - Подходящие для паролей KDF.
scrypt,pbkdf2. - Сравнение в постоянное время.
timingSafeEqual.
webcrypto (Web Crypto API) тоже встроен с 18.x, но для типовых
backend-задач удобнее классический crypto.
import { randomBytes, randomUUID } from 'node:crypto';
const sessionToken = randomBytes(32).toString('base64url');
const requestId = randomUUID(); // v4 по умолчанию
base64url, удобный формат для токенов в URL и cookie: только
URL-safe символы, без +///=. Длина 32 байт даёт 256 бит
энтропии, достаточно для session tokens.
2. Хеширование паролей
Главное правило: никогда не хешируй пароль через SHA-256/MD5/SHA-1 напрямую. Эти алгоритмы спроектированы быть быстрыми. На GPU они дают миллиарды хешей в секунду. Утечка хеш-таблицы, и за минуты brute-force.
Для паролей нужна функция, специально замедленная по CPU и/или памяти: KDF, key derivation function.
Из коробки в Node:
- scrypt. CPU + память. Дефолт по простоте, безопасный.
- pbkdf2. Только CPU, нужно много итераций (миллионы), GPU всё равно эффективнее.
Внешние:
- bcrypt. Старый стандарт. Хорошо проверен. Ограничен 72 байтами пароля.
- argon2. Победитель Password Hashing Competition 2015. OWASP рекомендует argon2id. Лучший выбор по 2026.
В этом курсе пишем на нативном scrypt: не нужны зависимости, защита
от GPU достаточная. Если в проекте уже есть argon2, оставь его, не
переписывай.
3. hashPassword и verifyPassword: авторская реализация
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scrypt = promisify(scryptCb);
const PARAMS = {
N: 2 ** 14, // CPU/memory cost (16384), ~150 ms на современной машине
r: 8, // block size
p: 1, // parallelism
keyLen: 64, // длина выхода в байтах
saltLen: 16,
} as const;
type Algo = 'scrypt';
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(PARAMS.saltLen);
const derived = (await scrypt(password, salt, PARAMS.keyLen)) as Buffer;
return [
'scrypt' satisfies Algo,
PARAMS.N,
PARAMS.r,
PARAMS.p,
salt.toString('base64url'),
derived.toString('base64url'),
].join('$');
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const parts = stored.split('$');
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
const [, n, r, p, saltB64, hashB64] = parts as [string, string, string, string, string, string];
const salt = Buffer.from(saltB64, 'base64url');
const expected = Buffer.from(hashB64, 'base64url');
const derived = (await scrypt(password, salt, expected.length, {
N: Number(n),
r: Number(r),
p: Number(p),
})) as Buffer;
return derived.length === expected.length && timingSafeEqual(derived, expected);
}
Что важно:
- Salt делаем случайный 16 байт. Никогда не “один на всех”, иначе rainbow-tables возвращаются.
- Параметры в формате. Если завтра поднимем
Nдо2 ** 15, старые хеши сN = 2 ** 14всё равно проверяются (мы читаемNиз строки), но при следующем логине обновим хеш. timingSafeEqual, сравнение за постоянное время.Buffer.equalsвыходит на первом отличии и через таймер можно угадывать байт за байтом.timingSafeEqualвсегда обходит весь буфер.- Длина буферов проверяется до сравнения.
timingSafeEqualбросаетRangeError, если длины разные.
4. UUID и случайные идентификаторы
import { randomUUID } from 'node:crypto';
const id = randomUUID(); // 'a3f1b2c4-...' v4 по умолчанию
UUID v4 это 122 бита случайности, совершенно достаточно для идентификаторов в базе. Минус, отсутствие порядка: вставка в B-tree-индекс по UUID v4 идёт “в случайные страницы”, что плохо для write-heavy workloads.
UUID v7 (стандартизирован в 2024), сначала timestamp, потом случайность.
Сохраняет порядок вставки, лучше для индексов. Node randomUUID() в
2026 году ещё v4. Если нужен v7, бери uuid из npm:
import { v7 as uuidv7 } from 'uuid';
const id = uuidv7(); // '018f...' лексикографически растёт со временем
Для PRIMARY KEY в Postgres UUID v7 это дефолт-2026. Для сессий и prelink-tokens, v4 проще и не выдаёт временной метки клиенту.
5. randomPrefetcher: пул случайных байтов
randomBytes(n) синхронный по умолчанию (асинхронный есть, через
callback). На проде это редко проблема: 32 байта набираются за
микросекунды. Но если ты выдаёшь токены тысячами в секунду, имеет смысл
завести пул.
Идея: фоном поддерживаем буфер случайных байтов, выдаём из него по
запросу. Если буфер кончился, перенаполняем. Это не “безопаснее” чем
прямой randomBytes, это про latency: каждый запрос на токен берёт из
готового буфера, без вызова syscall.
import { randomFill } from 'node:crypto';
const POOL_SIZE = 64 * 1024; // 64 KB
class RandomPrefetcher {
private pool: Buffer;
private offset: number;
private filling: Promise<void> | null;
constructor(size = POOL_SIZE) {
this.pool = Buffer.alloc(size);
this.offset = size;
this.filling = null;
}
async take(n: number): Promise<Buffer> {
if (this.offset + n > this.pool.length) {
await this.refill();
}
const slice = Buffer.from(this.pool.subarray(this.offset, this.offset + n));
this.offset += n;
if (this.offset > this.pool.length / 2 && this.filling === null) {
this.refillBackground();
}
return slice;
}
private refill(): Promise<void> {
if (this.filling !== null) return this.filling;
this.filling = new Promise((resolve, reject) => {
randomFill(this.pool, (err) => {
if (err) {
this.filling = null;
reject(err);
return;
}
this.offset = 0;
this.filling = null;
resolve();
});
});
return this.filling;
}
private refillBackground(): void {
this.refill().catch(() => {
// ошибка обновления фонового пула не должна валить процесс
});
}
}
Когда нужен пул:
- Высокий QPS на токены/UUID-ы (десятки тысяч/сек).
- Слабые VM/контейнеры с нехваткой энтропии (раньше было больно на AWS micro-instances, теперь меньше).
Когда не нужен:
- Обычный API на сотни RPS, преждевременная оптимизация.
- Любые случаи, где код должен пройти security-аудит: пул, доп. поверхность для проверки. Лучше одна понятная функция.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.