Опирается на правила:
R-SEC-CRYPTO-1…R-SEC-CRYPTO-5иR-SEC-CRYPTO-X1из Security Style Guide → раздел 5. Криптография в коде.
Важно знать
- Пароли:
argon2(argon2id) илиbcryptчерез npm. НикогдаcreateHash('md5'|'sha1'|'sha256')без соли и KDF.- Random:
crypto.randomBytes,crypto.randomUUID,crypto.randomInt— только они для security-целей.Math.random()запрещён для токенов, nonce, ключей.- Симметричное шифрование:
createCipheriv('aes-256-gcm', key, iv)с 12-байтным рандомным IV на каждый encrypt.- IV reuse в GCM полностью ломает confidentiality — IV хранится вместе с ciphertext, генерируется заново каждый раз.
- JWT:
passport-jwt+jwks-rsa.jwt.decode()безjwt.verify()— критическая ошибка, подпись не проверяется.- Hardcoded ключи/IV — запрещены. Только env / Vault / KMS, инжект через
ConfigService(NESTBOOT-4).- eslint-plugin-security +
semgrep(p/typescript+p/nodejs) ловят weak crypto и hardcoded credentials статически (R-SEC-SAST-2).
Не пиши свою криптографию — используй стандартные библиотеки и Node-встроенный node:crypto. Правило означает не «не понимай», а «не реализуй примитивы самостоятельно, не выбирай алгоритмы вне перечня, не self-mixing». UCP задаёт минимальный набор: один password hasher, один symmetric cipher, один JWT verifier.
Пароли — argon2 (argon2id)
R-SEC-CRYPTO-1: один правильный hasher.
// src/security/password.service.ts
import { Injectable } from '@nestjs/common';
import * as argon2 from 'argon2';
@Injectable()
export class PasswordService {
async hash(raw: string): Promise<string> {
return argon2.hash(raw, { type: argon2.argon2id });
}
async verify(hash: string, raw: string): Promise<boolean> {
return argon2.verify(hash, raw);
}
}
argon2id — победитель Password Hashing Competition 2015, устойчив к GPU и side-channel атакам. Параметры по умолчанию библиотеки (memoryCost: 65536, timeCost: 3, parallelism: 4) соответствуют OWASP-рекомендациям. При необходимости ужесточить:
argon2.hash(raw, {
type: argon2.argon2id,
memoryCost: 2 ** 17,
timeCost: 4,
parallelism: 2,
});
Если argon2 недоступен (среда без native bindings), используй bcrypt с saltRounds: 12:
import * as bcrypt from 'bcrypt';
const hash = await bcrypt.hash(password, 12);
const ok = await bcrypt.compare(raw, hash);
Примеры на домене Customer:
// src/customer/use-case/register-customer.handler.ts
@Injectable()
export class RegisterCustomerHandler {
constructor(
private readonly customerRepository: CustomerRepository,
private readonly passwordService: PasswordService,
) {}
async execute(cmd: RegisterCustomerCommand): Promise<Customer> {
const hashed = await this.passwordService.hash(cmd.password);
const customer = Customer.create(cmd.email, hashed);
return this.customerRepository.save(customer);
}
}
// src/customer/use-case/authenticate-customer.handler.ts
@Injectable()
export class AuthenticateCustomerHandler {
constructor(
private readonly customerRepository: CustomerRepository,
private readonly passwordService: PasswordService,
) {}
async execute(cmd: AuthenticateCustomerCommand): Promise<AuthResult> {
const customer = await this.customerRepository.findByEmail(cmd.email);
if (!customer) {
throw new UnauthorizedException('Invalid credentials');
}
const ok = await this.passwordService.verify(customer.passwordHash, cmd.password);
if (!ok) {
throw new UnauthorizedException('Invalid credentials');
}
return AuthResult.ofCustomer(customer);
}
}
Ошибка, которую ловит eslint-plugin-security (правило detect-possible-timing-attacks), — сравнение хешей через ===. argon2.verify / bcrypt.compare защищены от timing-атак внутри.
// КАТАСТРОФА
const isMatch = storedHash === createHash('sha256').update(password).digest('hex');
SHA-256 без соли — взламывается rainbow-tables за секунды. Semgrep-правила p/nodejs помечают все вызовы createHash с известными слабыми алгоритмами.
crypto.randomBytes для security
R-SEC-CRYPTO-2: только встроенный node:crypto.
import { randomBytes, randomUUID, randomInt } from 'node:crypto';
// 256-битный токен сброса пароля для Customer
const resetToken = randomBytes(32).toString('hex');
// UUID v4 для идентификатора заказа
const orderId = randomUUID();
// случайный int в диапазоне [0, 1000000) — для OTP-кода
const otp = randomInt(0, 1_000_000).toString().padStart(6, '0');
Math.random() — детерминированный PRNG с предсказуемым seed. Attacker, зная алгоритм и начальные условия, восстанавливает sequence. Для security-целей это катастрофа:
// КАТАСТРОФА
const sessionToken = Math.random().toString(36).slice(2);
const apiKey = Date.now().toString(16) + Math.random().toString(16);
Math.random() допустим только для не-security нужд: jitter в retry-политиках (R-RES-RE-3), shuffle колекций для отображения, A/B-тест allocation без security-импликаций.
AES-256-GCM для symmetric
R-SEC-CRYPTO-3: один правильный режим.
// src/security/aes-gcm.service.ts
import { Injectable } from '@nestjs/common';
import {
createCipheriv,
createDecipheriv,
randomBytes,
} from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const AUTH_TAG_BYTES = 16;
@Injectable()
export class AesGcmService {
encrypt(plaintext: Buffer, key: Buffer): Buffer {
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, encrypted]);
}
decrypt(data: Buffer, key: Buffer): Buffer {
const iv = data.subarray(0, IV_BYTES);
const authTag = data.subarray(IV_BYTES, IV_BYTES + AUTH_TAG_BYTES);
const ciphertext = data.subarray(IV_BYTES + AUTH_TAG_BYTES);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
}
Пример на домене Order — шифрование платёжных данных перед записью:
// src/order/use-case/store-payment-details.handler.ts
@Injectable()
export class StorePaymentDetailsHandler {
constructor(
private readonly aesGcm: AesGcmService,
private readonly config: ConfigService,
private readonly orderRepository: OrderRepository,
) {}
async execute(cmd: StorePaymentDetailsCommand): Promise<void> {
const key = Buffer.from(this.config.get<string>('ENCRYPTION_KEY_HEX')!, 'hex');
const encrypted = this.aesGcm.encrypt(Buffer.from(JSON.stringify(cmd.details)), key);
await this.orderRepository.storeEncryptedPayment(cmd.orderId, encrypted.toString('base64'));
}
}
Почему AES-GCM:
- Authenticated encryption — GCM встроенно проверяет integrity через auth tag. Без проверки attacker модифицирует ciphertext, decrypt возвращает мусор без ошибки.
- Нет padding — отсутствуют padding-oracle атаки, характерные для CBC.
- 12-byte IV — стандарт для GCM, не 16 (NIST SP 800-38D).
Запреты:
// КАТАСТРОФА — ECB: одинаковый plaintext → одинаковый ciphertext, паттерны видны
createCipheriv('aes-256-ecb', key, null);
// КАТАСТРОФА — CBC без MAC: padding-oracle attack
createCipheriv('aes-256-cbc', key, fixedIv);
// КАТАСТРОФА — фиксированный IV в GCM: полный слом confidentiality при reuse
const FIXED_IV = Buffer.alloc(12, 0);
createCipheriv('aes-256-gcm', key, FIXED_IV);
Ключ — 32 байта (256 бит). aes-128-gcm допустим, но aes-256-gcm предпочтителен для новых сервисов.
TLS минимум 1.2
R-SEC-CRYPTO-4: не понижать.
Node по умолчанию выставляет tls.DEFAULT_MIN_VERSION = 'TLSv1.2' — не переопределяй. Для явного контроля:
import * as tls from 'node:tls';
// Outbound HTTPS-клиент — не понижать ниже 1.2
const agent = new tls.TLSSocket(socket, {
minVersion: 'TLSv1.2',
});
В NestJS-сервисах TLS обычно terminates на reverse-proxy (Nginx / Envoy / Ingress). Proxy:
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers on;
TLS 1.0/1.1 — deprecated, уязвимости BEAST и POODLE. Никогда не включай обратно.
JWT — passport-jwt + jwks-rsa
R-SEC-CRYPTO-5: подпись обязана проверяться.
// src/auth/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
secretOrKeyProvider: passportJwtSecret({
jwksUri: config.get<string>('JWKS_URI')!,
cache: true,
rateLimit: true,
}),
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
audience: config.get<string>('JWT_AUDIENCE'),
issuer: config.get<string>('JWT_ISSUER'),
algorithms: ['RS256'],
});
}
validate(payload: JwtPayload): AuthContext {
return AuthContext.fromPayload(payload);
}
}
jwt.decode() из библиотеки jsonwebtoken — не проверяет подпись:
// КАТАСТРОФА — attacker может подделать любой payload
import { decode } from 'jsonwebtoken';
const payload = decode(token); // подпись не верифицируется
// ХОРОШО — verify с секретом или публичным ключом
import { verify } from 'jsonwebtoken';
const payload = verify(token, publicKey, { algorithms: ['RS256'] });
Даже jsonwebtoken.verify принимай только от passport-jwt / @nestjs/passport через стратегию — вручную менеджить verify сложно и ошибки появляются при рефакторинге. Полный flow — в Auth → JWT validation.
Hardcoded ключи/IV — запрещены
R-SEC-CRYPTO-X1:
// КАТАСТРОФА — ключ в коде
const SECRET_KEY = 'MySuperSecretKey1234567890123456';
const FIXED_IV = Buffer.from('123456789012', 'utf8');
// КАТАСТРОФА — ключ в конфиге без env-подстановки
// config/default.yml:
// encryption:
// key: "MyHardcodedKey"
Любой с доступом к репо имеет ключ. Правильно:
// src/security/encryption.config.ts
import { registerAs } from '@nestjs/config';
import * as Joi from 'joi';
export const encryptionConfig = registerAs('encryption', () => ({
keyHex: process.env.ENCRYPTION_KEY_HEX!,
}));
export const encryptionConfigSchema = Joi.object({
ENCRYPTION_KEY_HEX: Joi.string().hex().length(64).required(),
});
// src/app.module.ts
ConfigModule.forRoot({
load: [encryptionConfig],
validationSchema: encryptionConfigSchema,
validationOptions: { abortEarly: true },
}),
Локально — .env (в .gitignore), продакшн — Vault / AWS Secrets Manager / k8s Secret. Подробнее — Секреты в коде и истории.
Gitleaks (pre-commit + CI) перехватывает случайные коммиты ключей. eslint-plugin-security правило detect-non-literal-regexp и semgrep p/nodejs помечают строковые паттерны, похожие на credentials.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
createHash('md5'\|'sha1'\|'sha256') для паролей | R-SEC-CRYPTO-1 | argon2.hash(raw, { type: argon2.argon2id }) |
bcrypt с saltRounds < 12 | R-SEC-CRYPTO-1 | bcrypt.hash(raw, 12) |
Math.random() для токенов / nonce / ключей | R-SEC-CRYPTO-2 | crypto.randomBytes(32) / crypto.randomUUID() |
createCipheriv('aes-256-ecb', ...) | R-SEC-CRYPTO-3 | createCipheriv('aes-256-gcm', key, iv) |
createCipheriv('aes-256-cbc', ...) без MAC | R-SEC-CRYPTO-3 | aes-256-gcm + auth tag |
| Фиксированный или повторяемый IV в GCM | R-SEC-CRYPTO-3 | randomBytes(12) на каждый encrypt |
| TLS 1.0 / 1.1 на reverse-proxy | R-SEC-CRYPTO-4 | ssl_protocols TLSv1.2 TLSv1.3 |
jwt.decode() без jwt.verify() | R-SEC-CRYPTO-5 | passport-jwt + jwks-rsa через стратегию |
| Hardcoded ключ / IV в коде или конфиге | R-SEC-CRYPTO-X1 | env + ConfigService + Vault/KMS |
Куда дальше
- SAST по коду —
eslint-plugin-securityиsemgrepловят weak crypto и hardcoded credentials. - Секреты в коде и истории — как хранить ключи шифрования и JWT signing keys.
- Реакция на findings — SLA по severity, suppressions со сроком.
- CVE в зависимостях —
npm audit/osv-scannerна argon2 и passport-jwt. - Container/image-уязвимости — TLS termination на reverse-proxy.