Опирается на правила: R-SEC-CRYPTO-1R-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-1argon2.hash(raw, { type: argon2.argon2id })
bcrypt с saltRounds < 12R-SEC-CRYPTO-1bcrypt.hash(raw, 12)
Math.random() для токенов / nonce / ключейR-SEC-CRYPTO-2crypto.randomBytes(32) / crypto.randomUUID()
createCipheriv('aes-256-ecb', ...)R-SEC-CRYPTO-3createCipheriv('aes-256-gcm', key, iv)
createCipheriv('aes-256-cbc', ...) без MACR-SEC-CRYPTO-3aes-256-gcm + auth tag
Фиксированный или повторяемый IV в GCMR-SEC-CRYPTO-3randomBytes(12) на каждый encrypt
TLS 1.0 / 1.1 на reverse-proxyR-SEC-CRYPTO-4ssl_protocols TLSv1.2 TLSv1.3
jwt.decode() без jwt.verify()R-SEC-CRYPTO-5passport-jwt + jwks-rsa через стратегию
Hardcoded ключ / IV в коде или конфигеR-SEC-CRYPTO-X1env + 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.