Опирается на правила:
R-SEC-CRYPTO-1…R-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 код.- Симметричное шифрование:
cryptography→AESGCMс рандомным 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 для паролей без KDF | R-SEC-CRYPTO-1 | argon2-cffi или bcrypt |
random.randint/random.getrandbits для токенов | R-SEC-CRYPTO-2 | secrets.token_urlsafe/secrets.token_bytes |
AES-ECB (AES.MODE_ECB) | R-SEC-CRYPTO-3 | AESGCM из cryptography |
| AES-CBC без MAC | R-SEC-CRYPTO-3 | AESGCM с authenticated encryption |
| Фиксированный nonce в AES-GCM | R-SEC-CRYPTO-3 | os.urandom(12) на каждый encrypt |
httpx/requests с verify=False | R-SEC-CRYPTO-4 | verify=True (default), для mTLS — verify=ca_path |
jwt.decode(..., options={"verify_signature": False}) | R-SEC-CRYPTO-5 | decode с ключом и проверкой алгоритма |
| Hardcoded ключи/nonce в коде | R-SEC-CRYPTO-X1 | env / 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.