Опирается на правила: R-SEC-CRYPTO-1R-SEC-CRYPTO-5 и R-SEC-CRYPTO-X1 из Security Style Guide → раздел 5. Криптография в коде.

Важно знать

  • Пароли: argon2-cffi (предпочтительно) или bcrypt. Никогда hashlib.md5/sha1/sha256 без salt+KDF.
  • Random: модуль secrets (secrets.token_bytes, secrets.token_urlsafe). Модуль random — только не-security код.
  • Симметричное шифрование: cryptographyAESGCM с рандомным 12-байтным nonce на каждый вызов encrypt.
  • TLS: минимум 1.2; для outbound-запросов httpx/requests не отключай верификацию (verify=True по умолчанию).
  • JWT verification: используй библиотеку с проверкой подписи (python-jose, PyJWT); ручной decode без проверки — критическая ошибка.
  • Hardcoded ключи/nonce — запрещены. Только secret-store/KMS + env.
  • bandit ловит B303/B324 (weak hash), B311 (random), B501/B502 (TLS-verify отключён).

Не пиши свою криптографию — используй стандартные библиотеки. В Python это означает argon2-cffi для паролей, secrets для токенов и cryptography (PyCA) для симметричного шифрования. Всё остальное — источник ошибок, которые bandit/semgrep обнаруживают, а компрометация домена Order или Customer — последствие.

Пароли — argon2-cffi

R-SEC-CRYPTO-1: один правильный hasher.

from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

_ph = PasswordHasher(
    time_cost=3,
    memory_cost=65536,
    parallelism=4,
)


class RegisterCustomerHandler:
    def __init__(self, customer_repo: CustomerRepository) -> None:
        self._repo = customer_repo

    def handle(self, command: RegisterCustomerCommand) -> Customer:
        hashed = _ph.hash(command.password)
        customer = Customer.create(email=command.email, password_hash=hashed)
        return self._repo.save(customer)


class LoginHandler:
    def __init__(self, customer_repo: CustomerRepository) -> None:
        self._repo = customer_repo

    def handle(self, command: LoginCommand) -> AuthResult:
        customer = self._repo.find_by_email(command.email)
        if customer is None:
            raise InvalidCredentialsError

        try:
            _ph.verify(customer.password_hash, command.password)
        except VerifyMismatchError:
            raise InvalidCredentialsError

        if _ph.check_needs_rehash(customer.password_hash):
            self._repo.update_password_hash(customer.id, _ph.hash(command.password))

        return AuthResult.of(customer)

Argon2 — победитель Password Hashing Competition 2015. Параметры time_cost, memory_cost, parallelism управляют вычислительной стоимостью. check_needs_rehash позволяет прозрачно поднимать параметры без принудительной смены паролей пользователями.

Альтернатива — bcrypt:

import bcrypt

hashed = bcrypt.hashpw(command.password.encode(), bcrypt.gensalt(rounds=12))
is_valid = bcrypt.checkpw(command.password.encode(), hashed)

В UCP дефолт — argon2-cffi. bcrypt допустим при миграции существующей базы хешей.

Запрещено:

import hashlib

# КАТАСТРОФА — нет salt, не KDF, rainbow-tables ломают за секунды
def hash_password(raw: str) -> str:
    return hashlib.md5(raw.encode()).hexdigest()

def hash_password(raw: str) -> str:
    return hashlib.sha256(raw.encode()).hexdigest()

bandit: B303 (use of MD5), B324 (use of weak hash functions). SpotBugs-аналог в Python — bandit -t B303,B324.

secrets для security-токенов

R-SEC-CRYPTO-2: только модуль secrets.

import secrets
import base64


class OrderTokenService:
    def generate_confirmation_token(self) -> str:
        return secrets.token_urlsafe(32)

    def generate_payment_nonce(self) -> bytes:
        return secrets.token_bytes(16)

    def generate_api_key(self) -> str:
        raw = secrets.token_bytes(32)
        return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()

Модуль random — детерминированный PRNG с предсказуемым seed. Attacker, зная алгоритм и примерное время, может восстановить seed и предсказать sequence. Использование для:

  • токенов подтверждения заказа;
  • CSRF-токенов;
  • API-ключей клиентов Product/Sber;
  • nonce для шифрования.

— катастрофично.

import random

# КАТАСТРОФА — predictable
token = str(random.randint(100000, 999999))
reset_token = hex(random.getrandbits(128))

bandit: B311 (standard pseudo-random generators not suitable for security/cryptographic purposes).

random приемлем только для:

  • jitter в retry policies (R-RES-RE-3);
  • shuffle не-security коллекций;
  • тестов, где нужна воспроизводимость.

AES-GCM для симметричного шифрования

R-SEC-CRYPTO-3: библиотека cryptography (PyCA), класс AESGCM.

import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM


class PaymentDataEncryptor:
    _NONCE_SIZE = 12

    def __init__(self, key: bytes) -> None:
        self._aesgcm = AESGCM(key)

    def encrypt(self, plaintext: bytes, aad: bytes | None = None) -> bytes:
        nonce = os.urandom(self._NONCE_SIZE)
        ciphertext = self._aesgcm.encrypt(nonce, plaintext, aad)
        return nonce + ciphertext

    def decrypt(self, data: bytes, aad: bytes | None = None) -> bytes:
        nonce = data[: self._NONCE_SIZE]
        ciphertext = data[self._NONCE_SIZE :]
        return self._aesgcm.decrypt(nonce, ciphertext, aad)

Ключ инжектируется через env:

import base64
import os

from cryptography.hazmat.primitives.ciphers.aead import AESGCM


def build_encryptor() -> PaymentDataEncryptor:
    raw = base64.urlsafe_b64decode(os.environ["PAYMENT_ENCRYPTION_KEY"])
    return PaymentDataEncryptor(key=raw)

Использование в Use Case:

class StorePaymentMethodHandler:
    def __init__(
        self,
        payment_repo: PaymentMethodRepository,
        encryptor: PaymentDataEncryptor,
    ) -> None:
        self._repo = payment_repo
        self._encryptor = encryptor

    def handle(self, command: StorePaymentMethodCommand) -> PaymentMethod:
        encrypted_pan = self._encryptor.encrypt(
            command.card_number.encode(),
            aad=command.customer_id.bytes,
        )
        method = PaymentMethod.create(
            customer_id=command.customer_id,
            encrypted_pan=encrypted_pan,
            last_four=command.card_number[-4:],
        )
        return self._repo.save(method)

aad (additional authenticated data) привязывает шифртекст к контексту — в примере к customer_id. Расшифровать тот же blob с другим customer_id не получится, даже зная ключ. Это защищает от подстановки записей.

Почему AES-GCM:

  • Authenticated encryption — встроенная проверка integrity через authentication tag. Без неё attacker может изменить ciphertext, и decrypt вернёт мусор неотличимый от данных.
  • No padding — нет padding-oracle атак (актуально для CBC).
  • 12-byte nonce — стандартный размер для GCM; нельзя переиспользовать один nonce с одним ключом.

Запрещено:

from Crypto.Cipher import AES

# КАТАСТРОФА — ECB, одинаковые блоки → одинаковые ciphertext-блоки
cipher = AES.new(key, AES.MODE_ECB)

# КАТАСТРОФА — CBC без MAC + фиксированный IV
cipher = AES.new(key, AES.MODE_CBC, iv=b"1234567890123456")

Фиксированный nonce/IV в GCM полностью ломает безопасность: nonce reuse раскрывает keystream и позволяет восстановить plaintext.

TLS — не отключай verify

R-SEC-CRYPTO-4: verify включён по умолчанию, явно не отключать.

import httpx


class SberPaymentGatewayClient:
    def __init__(self, base_url: str) -> None:
        self._client = httpx.Client(
            base_url=base_url,
            timeout=10.0,
        )

    def authorize_payment(self, payload: dict) -> dict:
        response = self._client.post("/v1/payments/authorize", json=payload)
        response.raise_for_status()
        return response.json()
# КАТАСТРОФА — MITM-атака становится тривиальной
response = httpx.post(url, json=payload, verify=False)
requests.get(url, verify=False)

bandit: B501 (request with verify=False), B502 (ssl.wrap_socket without cert check).

Для outbound-запросов к внутренним сервисам через mTLS — передавай клиентский сертификат, но не отключай серверную верификацию:

client = httpx.Client(
    cert=("/path/to/client.crt", "/path/to/client.key"),
    verify="/path/to/ca-bundle.crt",
)

TLS terminates на reverse-proxy (nginx/Envoy) — FastAPI-приложение получает plain HTTP внутри кластера. Proxy конфигурируется отдельно (ssl_protocols TLSv1.2 TLSv1.3).

JWT — проверка подписи обязательна

R-SEC-CRYPTO-5: ни при каких условиях не decode без verify.

from jose import jwt, JWTError
from jose.exceptions import ExpiredSignatureError


class JwtTokenVerifier:
    def __init__(self, secret: str, algorithm: str = "HS256") -> None:
        self._secret = secret
        self._algorithm = algorithm

    def verify(self, token: str) -> dict:
        try:
            return jwt.decode(
                token,
                self._secret,
                algorithms=[self._algorithm],
            )
        except ExpiredSignatureError:
            raise TokenExpiredError
        except JWTError:
            raise TokenInvalidError
# КАТАСТРОФА — decode без ключа не проверяет подпись
import jwt as pyjwt

payload = pyjwt.decode(token, options={"verify_signature": False})

options={"verify_signature": False} принимает любой подделанный JWT. Это классическая уязвимость: attacker изменяет payload (например, повышает роль), подпись остаётся невалидной, но сервер её не проверяет.

Hardcoded keys — запрещены

R-SEC-CRYPTO-X1:

# КАТАСТРОФА
SECRET_KEY = "super-secret-jwt-key"
ENCRYPTION_KEY = b"0123456789abcdef"
NONCE = b"fixed_nonce_1234"

Любой с доступом к репо или декомпилированному bytecode получает ключ. JWT signing key, encryption key, API token Product-сервиса — никогда не в коде.

Правильно — через pydantic-settings:

from pydantic_settings import BaseSettings
from pydantic import Field
import base64

from cryptography.hazmat.primitives.ciphers.aead import AESGCM


class CryptoSettings(BaseSettings):
    jwt_secret: str = Field(alias="JWT_SECRET")
    encryption_key_b64: str = Field(alias="ENCRYPTION_KEY_B64")

    model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}

    def encryption_key(self) -> bytes:
        return base64.urlsafe_b64decode(self.encryption_key_b64)
# .env (в .gitignore)
JWT_SECRET=<rotate-from-vault>
ENCRYPTION_KEY_B64=<rotate-from-vault>

Откуда брать значения — Vault/KMS/cloud Secret Manager. Смотри Секреты в коде.

Что запрещено

АнтипаттернПравилоЧто взамен
hashlib.md5/sha1/sha256 для паролей без KDFR-SEC-CRYPTO-1argon2-cffi или bcrypt
random.randint/random.getrandbits для токеновR-SEC-CRYPTO-2secrets.token_urlsafe/secrets.token_bytes
AES-ECB (AES.MODE_ECB)R-SEC-CRYPTO-3AESGCM из cryptography
AES-CBC без MACR-SEC-CRYPTO-3AESGCM с authenticated encryption
Фиксированный nonce в AES-GCMR-SEC-CRYPTO-3os.urandom(12) на каждый encrypt
httpx/requests с verify=FalseR-SEC-CRYPTO-4verify=True (default), для mTLS — verify=ca_path
jwt.decode(..., options={"verify_signature": False})R-SEC-CRYPTO-5decode с ключом и проверкой алгоритма
Hardcoded ключи/nonce в кодеR-SEC-CRYPTO-X1env / Vault / KMS через pydantic-settings
Самописный crypto-алгоритмR-SEC-CRYPTO-1стандартные библиотеки (argon2-cffi, cryptography)

Куда дальше

  • SAST по коду — bandit B303, B311, B501 ловят weak hash, random, verify=False → SAST по коду.
  • Секреты в коде и истории — ключи через env, не в репо → Секреты в коде и истории.
  • CVE в зависимостях — pip-audit проверяет argon2-cffi, cryptography на CVE → CVE в зависимостях.
  • Container/image-уязвимости — TLS-терминация на reverse-proxy → Container/image-уязвимости.
  • Реакция на findings — SLA и suppressions для crypto-findings → Реакция на findings.